* feat(db): main agent, main conversation, user model and delegated task foundation
The product needs one main agent that leads a main conversation, knows the
user across spaces, and hands work to visible specialists. Today none of
that exists in the data model: "Chief" is a client-side naming convention,
the "Hauptthread" is a group the web client creates by name, and nothing
stores what the agents know about the user.
This adds the shared foundation the Phase 1 features build on:
- Bot.kind ("main" | "specialist", one main bot per user) plus capability
fields (tags, builtin tool allowlist, model tier).
- ChatGroup.kind ("group" | "main", one main conversation per user) and
ChatGroup.leadBotId.
- UserProfile and UserFact with category, confidence, source, consent
status and per-space visibility; embeddings are stored as float arrays
because none of the Postgres images ship pgvector.
- DelegatedTask for work the main bot hands to a specialist.
- SpaceModelTier to map a model tier to a connected model per space.
The migration backfills each user's onboarding bot in the home space as the
main bot and turns an existing "Hauptthread" group containing it into the
main conversation. CHECK and foreign key constraints on existing tables are
added NOT VALID and validated in a separate migration, as elsewhere in this
repo.
New db helpers ensure the main bot and the main conversation (idempotent and
race-safe, repairing a lost lead), add same-space specialists to the main
conversation and announce it with a group.updated event, and load the user
context a run may see. The main conversation is exempt from the two-member
floor and cannot have its members replaced through a group update. The
contracts expose the new fields and the user model and delegated task
schemas.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
* feat(adapters): every run knows who it is and who the user is, in the user's time zone
Runs dropped the bot's name, title and description whenever custom
instructions existed, always stated the time in UTC, and knew the user only
through free-form durable memory. Groups with a lead, like the main
conversation, still got the peer-group lines that forbid handing work back.
And every group follow-up run failed during setup: the inbound handoff lookup
selected a `bot` relation that Message does not have, which Prisma rejects.
- Instructions now open with the bot's identity, then its own instructions,
a main-agent role paragraph for the main bot, the current time in the
user's time zone with its offset (UTC when unknown), and a `<user>` block
built from loadUserContext: profile fields and the active facts visible in
the run's space, escaped, clipped and framed as data rather than
instructions. Runs that reply in a shared messaging channel get only the
name, zone, locale, language and style, matching how those runs already
leave out durable memory.
- run_subagent helpers receive the identity, time and user block through a
new optional AgentRunRequest.subagentContext instead of nothing.
- Groups with an active lead get lead and specialist lines and matching
inbound handoff instructions. Members may hand a stage back to the lead and
the lead may delegate the next stage; the bounce rules still apply between
members. Ordinary groups keep today's lines.
- The teammate directory is rendered only when the run has message_bot, which
group runs never have, and tool guidance names a tool only when the run has
it.
- The auto-review judge, history compaction and the team chat judge read the
time in the user's zone, and schedule_create defaults to it.
- The inbound handoff sender is read from the handoff block and looked up
among the user's bots; a Postgres test covers the follow-up run, the prompt
it receives, and handing the result back to the lead.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
* feat(memory): learn the user profile from conversations; add built-in semantic memory
Why: the user profile only knew what onboarding wrote, so people had to repeat themselves,
and semantic recall needed a hosted or separately run memory service. The profile now grows
from everyday conversations, and recall can run on the user's own server with a local
embeddings endpoint (for example llama.cpp). Both stay optional and are tested offline.
What:
- adapter-kit: EmbeddingProvider contract (describe, embed) and the profile.consolidate job
(payload schema and a replace-keyed job factory, one pending job per user).
- adapters: OpenAI-compatible embeddings (POST {baseUrl}/embeddings through the guarded
OpenAI-compatible fetch, batching, bounded responses, clear errors), a deterministic
emulator, an in-process /v1/embeddings server emulator, and a shared conformance suite.
- adapters: "builtin" semantic memory stored in Postgres (semantic_memory_items with Float[]
embeddings, cosine ranking in process, bot/shared/history scopes matching the existing
providers), registered in the memory provider factory and configured through
memory.connectProvider with an embeddings URL, model and optional API key. Private
endpoints require the deployment owner, like the other local memory providers.
- adapters: profile consolidation reads the user's settled app conversations since
UserProfile.consolidatedAt (never messaging channels), asks the main bot's model for strict
JSON facts and an updated summary, drops restatements (embedding cosine when built-in
memory is configured, normalized text otherwise), stores sensitive categories as pending,
and advances the cursor with a compare-and-set. A reconciler hook enqueues it at most daily
per active user; it skips under the scripted runtime and without a usable model.
- db: additive migration 20261002210400_ws4_semantic_memory.
- web: a "Built-in" option in Memory settings; AI data sharing names the embeddings server.
Tests: new embedding conformance, OpenAI-compatible wire, built-in provider and consolidation
unit tests; new Postgres suites for built-in memory (including a real loopback embeddings
server through the memory provider resolver) and for consolidation and its scheduling query,
both added to the integration harness. The settings E2E spec now opens the new form.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
* feat(models): map model tiers per space and let bots and helpers ask for one
A space can now map the tiers fast, standard and code to connected models,
including several models served by one OpenAI-compatible router endpoint
(one credential, many model ids). A bot can ask for a tier, and a single
run_subagent call can ask for one. A tier with no model mapped falls back
to the usual model choice, so nothing breaks before tiers are set up.
- db: SpaceModelTier repo (list, find, set or clear); tier mappings are
copied into a new space like model and voice preferences; createBot
carries a tier, so duplicates keep it and spawned bots inherit it.
- contracts/api: models.tiers.list and models.tiers.set;
models.probeOpenAiCompatible can list a connected server's models by
credential id with its stored address and key; bots.update accepts
modelTier and keeps a pinned model and a tier mutually exclusive, so a
model picked on a surface that knows no tiers (mobile) is never silently
overridden. Thinking-level validation follows the tier's model. AI
consent status discloses tier models; desktop local settings allow the
tier procedures.
- adapters: selectConfiguredModel runs a mapped tier ahead of the pinned
model and the space default; the executor resolves bot tiers for runs
and model lookups, and helper tiers through resolveModelTier.
run_subagent takes model_tier; an explicit model wins over it.
- web: a collapsed Tiers section in model settings (shown once a model is
connected) and mapped tiers as choices in the bot Model select.
- docs: tiers in self-host model setup.
- tests: resolution order and fallbacks (selection, executor, Pi helper),
repo, API and contract units, authorization and offline Pi Postgres
suites, and a model-tiers web E2E spec.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
* fix(adapters): state the consolidation time in the user's zone; name the memory migration after its content
Profile consolidation now passes the profile's time zone to the shared time
instruction, which takes the zone since every run states the time in the
user's zone. The semantic memory migration is renamed after what it creates.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
* fix(adapters): keep the user's summary and facts out of replies to other owners' agents
An agent connection wakes a bot with another owner's message, and the reply
goes back to that owner. Such runs now get only the basics of the user block
(name, time zone, locale, language, style), like runs in shared messaging
channels, so confirmed facts such as health never reach another person's
agent.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
* feat(profile): readable, writable and forgettable user model
The main agent is meant to know the user, but nothing could read or change
the user model the foundation added. Onboarding pasted the user's answers
into the first bot's instructions as a "User Context" block, and the user
had no way to see or remove what their bots knew about them.
This makes the user model a user-controlled resource:
- db: a profile repository scoped by user. One atomic update writes the
account name, profile fields, new facts (deduplicated by normalized
text, capped per user), fact edits including per-space visibility, and
confirmations. Health and values facts wait for confirmation unless the
user wrote them. Forgetting hard-deletes facts by id, category or all,
clears the summary that may restate them and marks earlier chats as
consolidated so they cannot teach them again; forgetting everything also
clears the profile fields. Writes take a per-user advisory lock.
- api/contracts: profile.get, profile.update and profile.forget. Unknown
fact ids are a 404 on update and ignored on forget; spaces outside the
user's memberships are rejected.
- adapters: an update_user_profile tool for the main bot only, hidden from
specialists and shared messaging channels and refused if called anyway.
Facts carry source "conversation" and the bot, thread and message they
came from. The scripted runtime records "learn about me: [category:]
statement" for tests.
- web: onboarding writes the name, time zone (browser default, now
prefilled and editable), locale, chosen language, communication style
and the hobby and work-style answers to the profile instead of
overwriting the bot's instructions, and the harvested memory section no
longer repeats them. A new "About you" settings section lists profile
fields and facts by category with their origin, confirms pending facts,
and forgets one fact or everything.
Tests cover the contracts, the repository (unit and PostgreSQL), tool
selection and refusal, the scripted trigger, the onboarding mapping and
view helpers, an API suite that runs the tool through the executor with a
real main bot, authorization for the new procedures, and an E2E spec.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
* feat(delegation): main bot hands work to specialists as tracked tasks
The main agent had no way to give a specialist a piece of work and keep
track of it. message_bot is a fire-and-forget peer message whose work stays
hidden from the user, and handoff_to_bot ends the main bot's turn and only
works between members of one group. Delegation now runs the specialist's
work where the user follows the main agent and brings the result back so
the main agent can close the task.
- delegate_task({bot, title, brief, deadline?}) is offered to the main bot
only and refused unless the caller leads the main conversation. It
resolves the specialist by id or name across the user's spaces, creates a
DelegatedTask and posts a task card in the main conversation.
- A specialist from the main conversation's space joins it and works there
in a run with the new trigger "delegation", which the peer filters do not
hide, so its progress, asks and approvals appear in the main conversation
with the specialist as sender. A specialist from another space works in
its own chat; its written result is posted into the main conversation as
its message.
- The executor mirrors the run on its task (running, waiting, done, failed,
cancelled) after the run starts and when each attempt ends, and updates
the card in place through thread.message.updated. Done and failed tasks
wake the main bot in the main conversation with the result. The job
reconciler settles tasks whose run ended without the executor (stop,
clear, archive, deletion, crash).
- tasks.list({threadId?, status?}) returns the caller's tasks for the
current space.
- The task card shows who, what and status with Lingui labels; the brief
opens on demand. The message_bot card no longer shows the recipient's
unrelated overall status or hard-coded German copy.
- The scripted runtime understands "delegate to the bot named X: ..." and
the task wake prompt; activity rows name delegation runs by task title.
Tests: unit tests for the prompts, status mapping, card, reducer, tool
selection and scripted runtime; a Postgres suite for same-space, group,
other-space, ask and answer, failure, stop and replay; tasks.list in the
authorization suite; and an end-to-end spec for the whole flow.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
* fix(adapters): show the main agent the specialists it can delegate to
The main conversation is a group run, and group runs no longer list the
teammate directory, so the main agent could not see the names of the
specialists delegate_task expects. Runs that have delegate_task now list
the user's specialists across the spaces they belong to, those next to the
conversation first and others marked with their space, and that list
replaces the teammate directory so no bot appears twice. It also says when
to prefer delegate_task over handoff_to_bot.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
* feat(inbox): show what specialists need from the user in the main conversation
Specialists ask questions, request approvals and the computer screen in
their own threads, and the user only finds out by opening each thread. With
the main conversation as the single entry point, those requests have to
reach the user there, with the specialist as the visible sender.
- inbox.list returns, for the current space and the user's own bots, pending
asks and approvals (bot messages with a pending ask block whose run waits
for input), takeover requests, and unread specialist threads. Asks already
in the main conversation are left out; takeovers stay because their
controls live in the bot's own thread. Each item carries the bot, the
thread target, run and message ids, and the ask block for asks and
approvals.
- The main conversation shows the items above the composer. Asks and
approvals are answered in place through threads.answer with the source
thread target; takeovers and unread threads open the bot's thread. The
list refreshes when the bots' status, unread state or newest message
changes, every 15 seconds, and after an answer.
- The inbox shrinks and scrolls so it never pushes the composer out of view.
The transcript now clips its scroller: with no room left in the column,
the scroller's padding used to spill over whatever sat below it.
- The ask block schema is extracted from MessageBlock so the inbox contract
reuses it, and AskCard accepts a className.
Tests: unit tests for the item builder and the component, a Postgres suite
for the query (added to the harness list), inbox cases in the authorization
suite, and an E2E spec in which a specialist asks in its own thread and the
user answers from the main conversation.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
* fix(adapters): write learned facts under the profile's lock and limit
Profile consolidation wrote facts beside the user profile repository: without
its per-user lock, its duplicate check at write time, or its limit of 500
facts. A long-running profile could grow past the limit and then refuse the
user's own additions. Consolidation now takes the same lock, drops facts the
profile gained since the run read it, and keeps only what still fits.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
* fix(adapters): keep profile writes out of replies to other owners' agents
A run woken over an agent connection hears another owner's bot, not the
user, so update_user_profile is withheld there: nothing such a conversation
says can become a fact about the user. Detection moves ahead of tool
selection; the reduced user block uses the same flag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
* feat(capabilities): least-privilege builtin tools per bot and schedules on behalf
Every bot saw every builtin tool, and nothing outside the runtime's own
declaration kept a run to the tools it was given: the executor ran any tool a
runtime asked for, and the Pi runtime exposed all builtins whenever a caller
passed an empty tool list, which the safety checker, the history summarizer
and the team chat judge do on purpose. A specialist could not be kept to the
tools of its lane, bots had no capability tags, and the main bot could only
schedule routines for itself.
- A bot can carry a builtin tool allowlist. Its runs see only the allowlisted
builtins, and an empty allowlist means none. MAIN_ONLY_BUILTIN_TOOLS (empty
until the user profile and delegation tools land) never reach other bots.
applyTool refuses any tool the run did not expose, and an empty tool list
now means no tools for the Pi runtime and its subagents.
- A bot spawned by a restricted bot inherits the restriction and a duplicate
keeps it, so neither path widens a bot's tools.
- bots.create and bots.update accept tags, restrictBuiltinTools and
allowedBuiltinTools; the router rejects names that are not builtin tools.
bots.builtinTools lists the names a bot's allowlist can use.
- Bot settings get a Tags field and a switch with a tool checklist under
Advanced.
- The main bot's schedule_create takes an optional bot: one of the user's
bots in any space the user still belongs to. The routine belongs to that
bot in its space and wakes in the main conversation when the bot is a
member there, otherwise in its 1:1 thread. Routines record the bot that
scheduled them (additive migration), so schedule_list and schedule_cancel
also cover exactly the routines a bot scheduled for others. Cancelling by
a name that matches several routines is refused.
Tested with unit tests for the per-bot filter, the applyTool guard, the empty
tool list, and on-behalf resolution, creation, listing and cancelling; with
Postgres journeys for on-behalf routines waking across spaces and in the main
conversation and for allowlist enforcement through the scripted runtime; and
with a web E2E for the settings section.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
* test(adapters): give the consolidation unit tests the profile lock
The write transaction now takes the per-user profile lock, so the mocked
client answers the lock query, and the first case checks the lock is taken.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
* feat(adapters): run profile consolidation on the fast tier when one is mapped
Fact extraction is short structured work, so consolidation asks the model
resolver for the fast tier. The resolver takes a per-call tier and, when the
space has not mapped it, keeps the bot's own tier or model as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
* feat: land in the main conversation, led by the main bot
After onboarding the user ended up in a 1:1 chat with "Chief", and the
"Hauptthread" was a group the web client created and found by name. The
main bot had no conversation it actually led, and addressing another bot
there did nothing unless the user had added it as a member first.
Now the main conversation is a real place:
- bots.ensureMain creates or returns the main bot. Bootstrap and space
navigation return `main` ({ botId, groupId, spaceId }) once the main
bot exists; reading navigation never creates a bot.
- The main bot leads the main conversation and answers when nobody is
addressed: message routing, follow-ups, reactions and clearing the
thread fall back to the group's lead instead of its first member.
- Addressing a same-space bot that is not a member yet, by @Name or a
mention chip, makes it join before the message is routed, so it
answers in the conversation. Specialists from other spaces do not join.
- The main bot cannot be archived or deleted, the main conversation
cannot be archived or deleted or have its members edited, and child
bots never dissolve it. Renaming the main bot renames the conversation.
- Web: onboarding uses bots.ensureMain and finishes in the main
conversation. The main conversation is the first sidebar entry in every
space, shown with the main bot's avatar and name; from another space it
switches back to the home space. The main bot is not listed again among
the bots, its 1:1 thread stays reachable by URL. The client no longer
creates or detects the "Hauptthread" by name, refetches a group when it
changes, and shows the speaker of any of the user's bots in a group.
- Onboarding no longer resets the step when its bootstrap resolves after
the user has already moved on, and new spaces open in the app instead
of walking onboarding again.
Tests: group mention routing with a default responder, lead follow-ups,
child-bot guards, a journey for the main conversation (lead answers,
addressed bot joins, guards, rename), authorization for bots.ensureMain,
and an E2E spec that lands in the main conversation, gets an answer from
the lead, sees an addressed bot join, and reaches the conversation from
another space. Existing E2E specs start in the main bot's thread through
a helper or use the main conversation where that is what they test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
---------
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
39 KiB
Self-hosting evOo
The signed-in product is a long-running API, a Graphile Worker, Postgres, and a computer provider (Docker supervisor, E2B, Daytona, or Box). It is not a static site. The marketing site in apps/www can be hosted separately.
Local (source checkout)
Same as the README quick start: .env from .env.example, Postgres via Compose, pnpm sandbox:build, pnpm dev, then http://127.0.0.1:5173 (or http://localhost:5173 — both loopback hosts are trusted). Electron: pnpm --filter @rakazo/desktop dev while that stack is up, choosing Existing instance with that address. The desktop app's This computer option instead installs and runs the published images itself with Docker Compose (see Published images), using port 45173 by default so it can run alongside pnpm dev. If that port is occupied, the app selects and remembers another loopback port. The managed API gets a Docker-assigned loopback port; all desktop traffic uses the web origin.
For source development in WSL, keep the checkout and data directory in the Linux filesystem (for example, ~/evoo), and run pnpm dev as your normal user. The host-run supervisor matches bot container UID/GID to that user. If Docker Desktop container IPs are unreachable, set SANDBOX_CONTROL_VIA_LOOPBACK=true in .env; this publishes the token-protected control service on a random loopback port. Leave this unset for the Compose-hosted supervisor.
Compose bot homes mount only their own subdirectory of the application volume using Docker volume semantics. Docker's internal volume paths are never used as host bind mounts.
Published images (no checkout)
Pull Postgres and ghcr.io/elie222/rakazo/app into any empty folder. No clone or image build.
Requires Docker Engine 26+ (API 1.45+ for bot home volume subpaths), the Compose plugin, curl, and OpenSSL.
mkdir -p evoo && cd evoo &&
curl -fsSLO https://raw.githubusercontent.com/elie222/rakazo/main/infra/compose/install-images.sh &&
bash install-images.sh
The installer downloads docker-compose.images.yml and .env.images.example, creates .env with
random secrets, then pulls and starts the images. It preserves an existing .env when rerun. For
the installer secret list, non-reuse rules, and recovery, see
Self-host secrets checklist. To customize the public URL, image tag, or
optional providers before startup, run bash install-images.sh --prepare-only, edit .env, then
run bash install-images.sh. Flags may be combined in either order: --prepare-only, --local.
SANDBOX_PROVIDER defaults to docker. The images Compose file runs a sandbox supervisor
(from the app image, on the internal network only) and pulls ghcr.io/elie222/rakazo/computer.
Signup and local Docker computers work without an E2B account. Optional remote providers: set
SANDBOX_PROVIDER to e2b, daytona, or box and add the matching API key. The published-images
Compose stack requires SANDBOX_SUPERVISOR_TOKEN for every provider; leave it empty and compose up fails closed.
Optional: set OPENROUTER_API_KEY or connect a model in the UI after signup.
The example defaults to edge (main builds). Published images are linux/amd64 only for now;
arm64 publishing is paused until an arm64 runner with Docker buildx exists. Do not assume latest
is present until a stable release exists.
Open http://127.0.0.1:5173. The first registered user becomes the
deployment owner. Put TLS in front of :5173 for a public host and set the three public origins to
that HTTPS URL.
Images Compose binds web to loopback (127.0.0.1:5173). Terminate TLS on the host and proxy
there. Vite preview same-origin-proxies /api and /rpc, so do not expose :3100. Set
BETTER_AUTH_URL, WEB_ORIGIN, and API_URL to that same HTTPS origin, and set
RAKAZO_HOST to its hostname (for example, app.example.com).
app.example.com {
reverse_proxy 127.0.0.1:5173
}
Open Agent computer on a bot, or send a message that uses the desktop, to see
the local Docker computer. For in-stack Caddy plus remote E2B computers, use the
production Compose path and infra/compose/Caddyfile.prod
instead of this host proxy.
Restricted networks / mirror downloads
If the installer, Compose downloads, or image pulls are blocked, use the restricted-network guide for mirror settings and local files.
Bot computer resource ceilings
Each Docker computer runs Xvfb, a window manager and a full Chromium driven by an agent that decides for itself what to open, so it is capped. These defaults provide a starting point for the Docker computer topology:
| Variable | Default | Accepts |
|---|---|---|
RAKAZO_COMPUTER_MEMORY |
2g |
2g, 1536m, a byte count. Minimum 6m, Docker's own floor. Also caps swap, so the ceiling holds. |
RAKAZO_COMPUTER_CPUS |
2 |
Whole or fractional cores, e.g. 1.5 |
RAKAZO_COMPUTER_PIDS_LIMIT |
2048 |
A positive integer |
Set any of them to 0, none or unlimited to remove that ceiling. A malformed value fails the
supervisor at startup naming the variable, rather than surfacing later as a failed bot.
Docker Compose (single machine)
- Copy
.env.exampleto.envand setPOSTGRES_PASSWORD(openssl rand -hex 16), plusBETTER_AUTH_SECRET,ENCRYPTION_KEY, andSCREEN_PROXY_SECRETto independent long random strings (32+ characters; 64 hex forENCRYPTION_KEY). Docker sandboxes also need a dedicatedSANDBOX_SUPERVISOR_TOKEN. Keep existingENCRYPTION_KEYvalues so stored credentials stay decryptable. - Set
OPENROUTER_API_KEY(andCOMPOSIO_API_KEYif you want Plugins). - Build the computer image:
pnpm sandbox:build(Compose also builds it via thecomputerservice). docker compose --env-file .env -f infra/compose/docker-compose.yml up --build- Open the web origin (
http://127.0.0.1:5173by default). The first registered user becomes the deployment owner.
On Windows, if an older clone with core.autocrlf=true leaves the computer pane hung on boot (bash\r in sandbox logs): from a clean worktree, set git config core.autocrlf false, run git add --renormalize . && git checkout -- ., then rebuild with pnpm sandbox:build.
Compose runs Postgres, the sandbox supervisor (Docker socket), API, worker, and a Vite preview of the web app. Bot computers are sibling containers (evoo/computer:local) on separate per-bot networks; only the supervisor and screen proxy join each one. The API process does not get an unrestricted Docker socket; the supervisor owns the lifecycle.
Postgres stays on the Compose network only (not published on the host), matching the images
compose. Credentials come from .env (POSTGRES_PASSWORD is required). Prefer a URI-safe value
(openssl rand -hex 16); characters such as @ : / ? # % break the interpolated DATABASE_URL
inside Compose. Official Postgres images set user, password, and database only on first volume
init, so an existing pgdata volume keeps its original identity: keep those values in .env, or
change them in place with ALTER ROLE / rename. Recreate the volume only after a backup (or when
the data is disposable); docker compose down -v deletes all Postgres state. For host-side clients
(pnpm db:migrate, GUI tools),
add infra/compose/docker-compose.postgres-host.yml so Postgres is published on loopback
127.0.0.1:5433, or use
docker compose --env-file .env -f infra/compose/docker-compose.yml exec postgres sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"'.
Do not publish Postgres on a public interface.
The Docker supervisor is not published as its own image and is not exposed on the host. It runs from
the app image, stays on the internal Compose network, and holds the Docker socket because access to
it is equivalent to control of the Docker host. Docker sandboxes require SANDBOX_SUPERVISOR_TOKEN
(API, worker, supervisor). SCREEN_PROXY_SECRET signs browser-screen capabilities (API and web
proxy). Keep both distinct from BETTER_AUTH_SECRET.
New credentials use versioned AES-GCM with per-record salt and row-bound AAD. Legacy ciphertext stays readable.
On a VPS, put TLS in front of :5173 (or serve the web build behind your proxy) and set:
BETTER_AUTH_URL=https://app.example.com
WEB_ORIGIN=https://app.example.com
API_URL=https://app.example.com
Cookies and CORS follow those origins. SIGNUPS_ENABLED / SIGNUP_ALLOWLIST seed the signup
policy when the API starts for the first time. They are not reapplied on restart, so configure them
before that first start.
With a nonempty signup allowlist, users—including existing accounts—must verify their email to sign in. Configure SMTP below before enabling an allowlist or upgrading an allowlisted deployment.
For a public deployment, configure SMTP and an allowlist before the API's first start. Keep an installation without email on a trusted local network.
Verification and password recovery email
Password changes for signed-in users require no email configuration. Forgotten-password recovery appears on sign-in only when a transactional email provider is available. evOo uses a provider-neutral contract and ships an SMTP adapter, so Amazon SES, Resend, and self-hosted SMTP servers use the same configuration:
SMTP_URL=smtps://smtp-user:replace-with-password@smtp.example.com:465
EMAIL_FROM=evOo <no-reply@example.com>
For Resend, use smtp.resend.com, username resend, and an API key as the password. For Amazon
SES, use the regional SMTP endpoint and SES SMTP credentials; these are different from ordinary AWS
access keys. Verify the sender/domain with the provider before testing delivery. Keep credentials in
.env, never in tracked files. smtps:// uses implicit TLS; smtp:// is also supported but requires
STARTTLS. evOo rejects configuration that disables TLS or certificate verification.
Local source development can use the offline email emulator instead. It captures email without contacting a provider:
EMAIL_EMULATOR=true
The emulator is forcibly disabled when NODE_ENV=production and requires the API to bind to a
loopback host. In NODE_ENV=development, captured messages are available from
http://127.0.0.1:3100/api/dev/emails with cache disabled; the API logs only delivery
metadata, never reset tokens. The inbox route is not registered in test, staging, or production.
Logging
Backend services write structured logs to stdout. LOG_LEVEL is debug, info, warn, error,
or off (default info). Production defaults to LOG_FORMAT=json; development defaults to pretty
unless you set json or pretty.
Axiom is optional. Set both AXIOM_TOKEN and AXIOM_DATASET for ingest to one shared dataset.
Services set service.name (evoo-api, evoo-worker, evoo-sandbox-supervisor,
evoo-updater). A partial Axiom config logs a one-time warning and stays off. AXIOM_EDGE is a
regional hostname; AXIOM_EDGE_URL must be https and wins when both are set.
Compose passes these into the API, worker, supervisor, and updater. Computer containers and updater child commands do not receive them.
Optional:
SIGNUPS_ENABLED=true
SIGNUP_ALLOWLIST=you@example.com,@company.com
SANDBOX_PROVIDER=docker # or none, e2b, daytona, box. Keep fake only for pnpm test.
AGENT_RUNTIME=pi # Keep scripted only for pnpm test.
WAKEUP_DRIVER=graphile
SANDBOX_IDLE_MS=600000 # pause the bot computer after 10 minutes idle
SANDBOX_COMMAND_TIMEOUT_MS=300000 # stop a shell command after 5 minutes
MAX_TOOL_CALLS_PER_TURN= # optional Pi turn tool-call fuse; unset/0 = unlimited
E2B_API_KEY= # when SANDBOX_PROVIDER=e2b
DAYTONA_API_KEY= # when SANDBOX_PROVIDER=daytona
BOX_API_KEY= # when SANDBOX_PROVIDER=box
To use an operator-controlled OpenAI-compatible server such as Ollama, LM Studio, llama.cpp, or MLX, list its model IDs and an endpoint that both the API and worker processes can reach:
RAKAZO_LOCAL_MODELS=qwen3:4b,llama3.1:8b,qwen3-vl
RAKAZO_LOCAL_MODELS_URL=http://127.0.0.1:11434/v1
RAKAZO_LOCAL_CONTEXT_WINDOW=32768
RAKAZO_LOCAL_MAX_TOKENS=4096
# Optional: model ids on this endpoint that accept images (screenshot computer tools).
RAKAZO_LOCAL_VISION_MODELS=qwen3-vl
The loopback default is suitable when running evOo from a source checkout. From containers,
prefer a stable LAN RFC1918 address (not Compose service DNS alone). On Docker Desktop,
host.docker.internal also works.
On Docker Desktop, a bot computer shell can often reach services bound to host 127.0.0.1
through that same hostname. Do not run sensitive unauthenticated services on loopback while
bots run, or firewall / block that path. Linux does not get host.docker.internal the same
way by default.
Only configure an endpoint you control: prompts, attachments, and tool results sent to that model
leave evOo through this URL. Leave RAKAZO_LOCAL_MODELS blank to disable the provider.
Each user can also connect their own OpenAI-compatible endpoint from Connect a model /
Settings → Models on web and mobile. Choose OpenAI-compatible, enter the server base URL
(for example http://127.0.0.1:8000/v1), the exact model id, and an optional API key.
Public hosts and ordinary hostnames need RAKAZO_OPENAI_COMPAT_ALLOW_PUBLIC=1 and HTTPS.
Literal private IP, loopback, and host.docker.internal targets do not. If that endpoint's model
accepts images, enable Supports images under Advanced when connecting so attachments and
screenshot computer tools stay available. Existing connections default to disabled. For centrally
managed endpoints, the deployment-wide fallback remains
RAKAZO_OPENAI_COMPATIBLE_VISION_MODELS=gpt4o-vision,llava.
Settings → Models → Tiers maps the tiers fast, standard, and code to models of your connections, per space. One router endpoint that serves several models (for example llama.cpp behind a model router) can cover all three, because each tier offers every model the server lists. A bot asks for a tier through its Model setting, and a helper can ask for one per call. A tier with no model mapped falls back to the usual model choice.
For servers that accept standard reasoning_effort, enable Supports thinking under
Advanced when connecting. The setting is saved on the connection (no env var or restart).
Existing connections default to disabled. Reconnect former Qwen-list or deployment-local models
via Settings → Models and turn it on; the old environment list is no longer read.
Enabled connections default to medium thinking. Web and desktop expose Thinking in a bot's
advanced settings; mobile inherits the same backend policy. evOo sends standard
reasoning_effort (minimal, low, medium, high, or none when off); the server owns
model-specific translation. Leave Supports thinking off when the server lacks standard effort
support. Existing token limits still apply; effort is not a separate reasoning-token budget.
Do not commit .env. Never put COMPOSIO_API_KEY, OpenRouter keys, or provider tokens in git, logs, or chat.
Optional messaging platforms (iMessage, Slack, WhatsApp, Telegram, Feishu/Lark) mount when their env credentials are set — see .env.example. Point a Feishu/Lark bot event subscription at /api/v1/messaging/webhook/lark (webhook/HTTP inbound only; do not enable long connection). Groups stay iMessage-only.
Choosing a computer provider
The Electron desktop app is a client of the same API. Docker and E2B still apply. On first launch, Electron asks the deployment owner whether bots should keep using Docker or run on this Mac as you. SANDBOX_PROVIDER=desktop is a separate, explicit provider that always runs commands on the service host.
- Published images (
docker-compose.images.yml) default toSANDBOX_PROVIDER=dockerwith a local supervisor and publishedghcr.io/elie222/rakazo/computerimage. No E2B account required. Optional: sete2b,daytona, orboxplus the matching API key for remote computers. - Docker is the quick-start default for published images and for a source checkout / full local Compose stack. Workspace bots share a persistent Team Computer by default; Private computers are optional. Keep the supervisor private, as the included Compose files do.
- E2B runs bot computers away from the evOo host and is a good choice for public or multi-user
production deployments. evOo checkpoints the portable workspace and browser-profile directory to
DATA_DIR; the E2B disk is a runtime cache, not the durable source of truth. - Daytona provides the same remote-computer contract through Daytona sandboxes. Configure
DAYTONA_API_KEYand optionallyDAYTONA_API_URL/DAYTONA_TARGET. - Box by ASCII provides a managed Linux desktop through
BOX_API_KEYand optionallyBOX_API_URL. evOo always creates or resumes boxes withnoEnv: true, keeps the portable workspace under/home/user/evoo-home, and refreshes a two-hour TTL. Box uses the shared Linux desktop runtime and protected port routes for concurrent bot desktops. Each bot has its own persistent Chrome profile; logins are not shared between bots. - Desktop provider / This Mac runs commands on the API/worker host. Docker stays the default. The Electron app asks once; if you choose This Mac, bots can use working directories under your home folder. Do not enable it on a public or shared service. macOS does not show its own permission dialog for this.
- Fake is only an emulator for verification.
- None boots the product without a computer host (fallback when Docker/supervisor is not configured, or when a remote provider is selected without its API key).
For provider configuration and health checks, see the provider setup guide.
Backup
./scripts/backup.sh
This dumps Postgres (pg_dump) and archives data/ into backups/<stamp>/. A missing
data/ produces an empty archive; database or archive errors fail the backup. Discard the
output directory of any failed run.
Public single-VM deployment
infra/compose/docker-compose.prod.yml runs the hosted product with Postgres, the API, worker, web app,
and automatic HTTPS through Caddy. It uses E2B for bot computers, so the VM never exposes a Docker
supervisor or browser containers. The root-equivalent updater sidecar is an explicit opt-in profile.
Before deploying to a new Ubuntu host, create and verify a key-only deploy account, then apply the
idempotent host-hardening baseline. It disables SSH passwords and root login, rate-limits SSH, allows
only SSH/HTTP/HTTPS through UFW, enables fail2ban, unattended security updates, AppArmor, audit rules,
and conservative kernel/network protections. Keep the provider console open until a fresh SSH login
succeeds after the script reloads SSH.
sudo DEPLOY_USER=deploy bash infra/compose/harden-host.sh
The production host also uses infra/compose/docker-daemon.json to enable live restore, bounded local
container logs, default no-new-privileges, and the kernel NAT path instead of Docker's userland proxy.
- Point an
A/AAAArecord such asapp.example.comat the VM and allow inbound TCP 80/443 and UDP 443. If you use Cloudflare, enable the proxy with Full (strict) TLS and copyCaddyfile.cloudflare.exampleto an operator-controlled path outside the public checkout. SetCADDYFILE_PATHto that absolute path. The example drops application requests that do not come from Cloudflare's published IP ranges; reconcile those ranges whenever Cloudflare publishes a change. A Cloudflare Tunnel can replace the public web listeners. - Clone the repository on the VM and create a root
.envwith production-only values. At minimum setPOSTGRES_PASSWORD,BETTER_AUTH_SECRET,ENCRYPTION_KEY,SCREEN_PROXY_SECRET,OPENROUTER_API_KEY, the API key for your selected sandbox provider,RAKAZO_HOST, and the three public origins. SetRAKAZO_DEPLOY_DIRwhen the checkout is not at the supported Linux default,/srv/evoo. Use URL-safe random values for database credentials. If you enable theupdaterprofile, also set a dedicatedRAKAZO_UPDATER_TOKEN(at least 32 characters) that differs fromBETTER_AUTH_SECRET,SANDBOX_SUPERVISOR_TOKEN, andSCREEN_PROXY_SECRET. - Keep registration allowlisted while the service is private:
NODE_ENV=production
RAKAZO_HOST=app.example.com
# Optional operator-owned override, for example the Cloudflare allowlist file:
# CADDYFILE_PATH=/etc/evoo/Caddyfile.prod
BETTER_AUTH_URL=https://app.example.com
WEB_ORIGIN=https://app.example.com
API_URL=https://app.example.com
SIGNUPS_ENABLED=true
SIGNUP_ALLOWLIST=owner@example.com,reviewer@example.com
# e2b, daytona, or box
SANDBOX_PROVIDER=e2b
AGENT_RUNTIME=pi
WAKEUP_DRIVER=graphile
DATA_DIR=/data
# Absolute path of this checkout as the Docker daemon sees it. /srv/evoo is the Linux default;
# set this explicitly for every other layout. See "The deploy directory must be one path" below.
RAKAZO_DEPLOY_DIR=/srv/evoo
RAKAZO_IMAGE_TAG=local
# Optional: required only with `--profile updater`.
# RAKAZO_UPDATER_TOKEN=replace-with-32-plus-character-updater-token
- Build the images from your checkout and start the stack, then verify its public health endpoint:
docker compose --env-file .env -f infra/compose/docker-compose.prod.yml \
build --build-arg GIT_SHA=$(git rev-parse HEAD)
docker compose --env-file .env -f infra/compose/docker-compose.prod.yml \
up -d --wait --pull never
curl --fail https://app.example.com/health
Build, do not pull, for a first deployment. RAKAZO_IMAGE_TAG ships as local, a tag no
registry serves, so the commands above build api, worker, and web from the checkout you just
cloned. The opt-in command under Updater sidecar builds updater when needed.
Passing GIT_SHA is what makes GET /health report a "revision"; a locally built image has no
other way to know its commit. Prebuilt images from the registry bake it in at publish time, so when
you switch to a release tag you should leave GIT_SHA unset — a value in .env would override what
the image already knows.
Once a release has been published you can switch this host to prebuilt images by setting
RAKAZO_IMAGE_TAG to that release tag and running pull followed by up -d --wait --pull never.
See Published images and tags for the tag contract.
The root .env is excluded from both Git and the Docker build context. The database, application data,
and Caddy certificates live in named Docker volumes.
The production Compose file pins Postgres and Caddy to multi-architecture manifest digests, and the published application/updater builds pin their base-image digests. Refresh those pins deliberately when taking upstream security updates; changing only the visible major tag does not change the content while a digest is present.
For the single-VM production layout, install infra/compose/backup-prod.sh as
/usr/local/sbin/evoo-backup and enable the supplied evoo-backup.timer. It creates a verified
Postgres custom-format dump plus an application-data archive under /var/backups/evoo, with mode
0600 and seven-day rotation. These local snapshots help with operator mistakes but are not a
substitute for an encrypted off-host backup or provider snapshot.
The scheduled backup uses /srv/evoo by default. For another deployment directory, set
RAKAZO_DEPLOY_DIR=/absolute/path/to/checkout in a root-owned /etc/evoo/backup.env
(mode 0600). The service reads this optional file on each run; the script uses the selected
checkout's .env and production Compose file. If the stack was started with a custom -p,
set the same COMPOSE_PROJECT_NAME in that file. For a manual run, export these variables instead.
When updating an existing backup installation, reinstall both the script and service unit,
then run systemctl daemon-reload.
Restore
For backups created by scripts/backup.sh, use an empty evoo database in the development
Compose stack, with application services stopped. The SQL import runs in one transaction and
stops on the first error, including conflicts with existing tables. Files are restored and
application services started only after the import succeeds. This script does not consume the
production snapshot's custom-format evoo.dump or appdata.tgz.
./scripts/restore.sh backups/<stamp>
Upgrade
A Compose deployment on a published release tag upgrades by moving that tag:
docker compose --env-file .env -f infra/compose/docker-compose.prod.yml pull api worker web
docker compose --env-file .env -f infra/compose/docker-compose.prod.yml \
up -d --wait --pull never api worker web
A deployment on the default local tag has no registry to pull from, so it upgrades by rebuilding
the checkout instead:
git pull
GIT_SHA=$(git rev-parse HEAD) docker compose --env-file .env -f infra/compose/docker-compose.prod.yml \
up -d --wait --pull never --build api worker web
up --wait does not report success until the new API is healthy and the worker and web containers
are running. The API's start command runs prisma migrate deploy before it serves, so migration
failure keeps health red. A failed CLI recreate does not auto-roll back; recover with the previous
RAKAZO_IMAGE_TAG (or rebuild local) and up -d --wait --pull never.
The updater sidecar has its own image and tag so an update never recreates the process performing
it. Move it deliberately by setting RAKAZO_UPDATER_IMAGE_TAG to the full sha-<commit> tag, then
running docker compose … pull updater && docker compose … up -d --wait --pull never updater.
Sidecar /apply and /rollback recover a failed recreate by redeploying the previously cached
image when possible; if that also fails, they report a possible mixed-version runtime.
Source checkouts (not Compose) still upgrade the old way: pull, rebuild with
GIT_SHA=$(git rev-parse HEAD), run pnpm --filter @rakazo/db migrate, then restart API and worker.
Product contracts stay compatible across cloud and self-hosted.
Space privacy-boundary migration
Before upgrading across migration 20260830200000_space_scope_names_and_user_credentials, check
that every existing model and voice credential still belongs to a member of its Space. This query
uses the pre-migration workspaceId column name and must return no rows:
SELECT 'model' AS credential_type, credential."id", credential."userId",
credential."workspaceId" AS "spaceId"
FROM "user_model_credentials" AS credential
LEFT JOIN "space_members" AS membership
ON membership."spaceId" = credential."workspaceId"
AND membership."userId" = credential."userId"
WHERE membership."id" IS NULL
UNION ALL
SELECT 'voice', credential."id", credential."userId", credential."workspaceId"
FROM "user_voice_credentials" AS credential
LEFT JOIN "space_members" AS membership
ON membership."spaceId" = credential."workspaceId"
AND membership."userId" = credential."userId"
WHERE membership."id" IS NULL;
The migration renames columns used by the API and worker and is therefore a coordinated cutover, not an online rolling migration. Stop the old API and worker, apply the migration, and start the new versions together. Its lock waits are bounded so contention fails the migration instead of leaving application traffic queued indefinitely.
Published images and tags
.github/workflows/publish-server-image.yml publishes to ghcr.io/<owner>/<repo>/…, derived from
${{ github.repository }} rather than hardcoded, so a fork's CI fills the fork's own namespace. For
this repository that is:
| Image | Contents |
|---|---|
ghcr.io/elie222/rakazo/app |
api, worker, web, and sandbox supervisor — one image, multiple commands |
ghcr.io/elie222/rakazo/computer |
Linux desktop used as each bot computer |
ghcr.io/elie222/rakazo/updater |
the updater sidecar, plus the Docker CLI |
infra/compose/docker-compose.images.yml is the no-checkout path for those app and computer tags
plus Postgres. The supervisor runs from the app image on the internal network only (not a separate
published supervisor image, and no host port). Production Compose (docker-compose.prod.yml) can
also pull the same app tags once RAKAZO_IMAGE_TAG is set to a published value.
If you deploy from your own fork, set RAKAZO_IMAGE and RAKAZO_UPDATER_IMAGE to your namespace —
your CI cannot publish into someone else's.
| Tag | Published on | Moves? |
|---|---|---|
local |
nothing — built locally by up --build |
rebuilt in place |
local-<full-commit> |
nothing — built on the server by a fork update | never |
vX.Y.Z, vX.Y |
release tags | conventionally no / on patch releases |
latest |
stable vX.Y.Z tags only (not prereleases) |
yes, to the newest stable release |
sha-<full-commit> |
every push and manual run | source-addressed; used by the updater sidecar |
edge |
pushes to main | yes, to the newest main build |
Every publish, including edge from main merges, is linux/amd64 only for now: the image builds
natively on the Linux runner and one manifest is assembled per image. arm64 publishing is paused
until an arm64 runner with Docker buildx exists. Until a
stable vX.Y.Z has been published, GHCR may only have edge and sha-* tags; do not pin
latest unless that tag exists in the registry.
Building the images yourself does not need QEMU. docker compose up --build builds for the host's
own architecture, and a fork publishing multi-arch images should do what publish-server-image.yml
does: build each architecture on a native runner (GitHub Actions provides ubuntu-24.04-arm for
public repositories) and merge the digests into one manifest. QEMU emulation
(docker/setup-qemu-action, binfmt) still works if you have no native arm64 machine, but it is
many times slower, hours rather than minutes for the computer image.
The updater resolves the newest stable vX.Y.Z source tag but deploys its sha-<full-commit> image,
not latest or a moving minor tag. A registry tag is not an OCI digest and GHCR package writers can
replace it, so the trust boundary remains this repository's publishing credentials. The workflow
reduces that boundary by using SHA-pinned actions, read-only pull-request jobs, digest-pinned base
images, SBOM/provenance output, and a GitHub build attestation. Operators who require registry-level
content addressing can pin RAKAZO_IMAGE outside the automatic updater to a verified digest.
Rollback never contacts the registry: it redeploys the previous tag from the local Docker cache, so a later tag move cannot change rollback content. Do not prune the previous application image until the next update has been accepted. If it is missing, rollback fails closed instead of pulling new content under an old tag.
To populate the registry the first time, run the workflow manually (workflow_dispatch) or push a
v* tag. A manual run produces sha-<full-commit>; only a stable vX.Y.Z tag (no prerelease
suffix) produces latest, and any v* tag produces semver tags. The updater ignores prereleases
and refuses the official path until a stable vX.Y.Z exists.
Updater sidecar
Compose production deployments offer an opt-in updater profile on a private control network.
Normal deployments do not start it or require its credential. To enable it, set a dedicated
RAKAZO_UPDATER_TOKEN and explicitly start the profile:
docker compose --env-file .env -f infra/compose/docker-compose.prod.yml \
--profile updater up -d --build updater
It exposes /health, /state, /plan, /apply, and /rollback at http://updater:7092 with
RAKAZO_UPDATER_TOKEN. Operator CLI upgrades above do not need it; the sidecar is for automated
apply/rollback over that private HTTP API.
The API cannot update itself — its image has no .git, and nothing inside the container would
restart it — so the work happens in a separate updater container that outlives the recreate:
- Official repository: resolves the newest stable release and its source commit with
git ls-remote --tags, pins the corresponding fullsha-<commit>image tag in.env, keeps the outgoing tag inRAKAZO_IMAGE_TAG_PREVIOUS, explicitly pulls the new image, then runsup -d --wait --pull never. No build runs on the server. - Fork (Advanced): a fork has no published images, so the sidecar fast-forwards the checkout in
RAKAZO_DEPLOY_DIRand runsup -d --build. This builds on the server and takes minutes rather than seconds. Point it only at a fork you control and have reviewed — the sidecar runs that Compose file through a root-equivalent Docker socket.
Updates and rollbacks run one at a time. A failed pull leaves running services alone; a failed recreate restores the previous environment
pin and attempts to redeploy the cached previous image. A failed fork build also restores the
pre-update branch and commit (including when checkout succeeded but merge did not) so a later
manual --build cannot deploy the rejected or unintended revision. Database migrations are not
reversed. The sidecar never recreates itself, never touches Postgres or Caddy, and never runs
migrations — that ordering belongs to the API start command.
Only https:// and ssh:// git remotes are accepted. Merges are fast-forward only. A dirty or
untracked source tree fails closed before anything runs (the application Dockerfile uses COPY . .).
The deploy directory must be one path
RAKAZO_DEPLOY_DIR is bind-mounted into the updater at the same path it is read from
(${RAKAZO_DEPLOY_DIR}:${RAKAZO_DEPLOY_DIR}), and that is load-bearing rather than tidy. Production
Compose defaults both sides to /srv/evoo; set the variable for any other layout. When the
updater runs docker compose -p <project> --file $RAKAZO_DEPLOY_DIR/infra/compose/docker-compose.prod.yml up -d,
the Compose CLI inside the container expands this file's relative bind mounts — ../../.env,
./Caddyfile.prod — against that path and hands the results to the daemon. The daemon has to be
able to resolve the same strings, or it silently creates empty directories where your .env and
Caddyfile should be. Compose makes the effective -p value available for interpolation but does
not automatically put it in a container's environment, so the production file explicitly assigns
COMPOSE_PROJECT_NAME to the updater. A standalone sidecar can instead set
RAKAZO_COMPOSE_PROJECT_NAME; the final fallback is evoo-prod. Without that propagation, a
stack started with -p something-else would be left alone while a second project with a new empty
Postgres volume came up beside it.
Deployments that layer a Compose overlay
RAKAZO_COMPOSE_FILE takes a list, separated the way Compose's own COMPOSE_FILE is
(: by default, or whatever COMPOSE_PATH_SEPARATOR says). Each entry becomes its own --file,
in the order given, so the updater reconciles the same stack the operator runs by hand:
RAKAZO_COMPOSE_FILE=infra/compose/docker-compose.prod.yml:ops/compose/overlay.yml
Every entry is validated separately and must stay inside RAKAZO_DEPLOY_DIR.
If the overlay adds a service built from the application image, name it in
RAKAZO_UPDATE_SERVICES (comma separated) so it is pulled, recreated and rolled back with the
rest. Otherwise an update leaves that service running the previous code:
RAKAZO_UPDATE_SERVICES=supervisor
These names are appended to the built-in api, worker, web, never substituted for them, so no
value here can drop a core service from an update.
The value therefore has to be the path the daemon sees, which is not always the path your shell sees:
- Linux. The daemon shares the host filesystem, so the checkout path is the answer:
/srv/evoois the default and supported production layout. SetRAKAZO_DEPLOY_DIRexplicitly when the checkout is elsewhere. - Docker Desktop (Windows/macOS). The daemon runs in a VM that mounts your drive somewhere else.
On Windows,
C:appears at/run/desktop/mnt/host/c, so a checkout atC:\Users\you\evooisRAKAZO_DEPLOY_DIR=/run/desktop/mnt/host/c/Users/you/evoo. Host Git may usecore.autocrlf=true; the updater ignores CR-only diffs so that does not block/apply. Verify the mount before deploying:
docker compose --env-file .env -f infra/compose/docker-compose.prod.yml \
--profile updater run --rm updater git -C "$RAKAZO_DEPLOY_DIR" log --oneline -1
That must print your checkout's HEAD. The two tempting wrong answers both fail: a native Windows
path is rejected by the daemon (mount denied: … too many colons, because the drive letter's
colon collides with the bind-mount separator), and /mnt/c/... fails silently — the container
starts, the mount is an empty directory, and the updater simply reports no checkout.
The updater's privileges
The updater holds the Docker socket, which is root-equivalent on the host. It is scoped as narrowly as that allows:
- No
ports, so nothing is published on the host. - Only on the dedicated
controlnetwork shared with the API. Caddy is not attached, so the reverse proxy has no route to the updater. - Every route except
/healthrequires the shared bearer token, compared in constant time. - The process environment carries only updater settings (
RAKAZO_UPDATER_TOKEN, deploy path, image name, project name). Application secrets stay in the bind-mounted.envthat Compose reads for interpolation; they are not loaded into this container. - The Docker CLI lives only in the updater image. The api, worker, and web containers keep
cap_drop: ALLand no socket.
Enabling the updater profile requires RAKAZO_UPDATER_TOKEN to be a dedicated random value (at
least 32 characters in production). It must differ from BETTER_AUTH_SECRET,
SANDBOX_SUPERVISOR_TOKEN, and SCREEN_PROXY_SECRET. Leave the profile disabled if you would
rather not grant the capability.
Other deployment layouts
API and worker need always-on processes; serverless request handlers are not sufficient. Use a
Node.js version supported by the root package.json, Postgres 16 and a persistent DATA_DIR volume shared by API and worker, with encrypted off-host
backups. The current home store uses a local filesystem, so deployments on separate hosts need a
shared filesystem; an object-storage adapter is not available yet.
Use the same HTTPS origin for the web app, /api, and /rpc. Preserve the authenticated screen
proxy routes. Choose a computer provider appropriate to the
service's trust boundary, and configure SIGNUPS_ENABLED and SIGNUP_ALLOWLIST before the API's
first start.
The optional marketing site in apps/www can be hosted separately.
Connect mobile clients
The iOS and Android app can also point at a self-hosted origin at runtime. On the sign-in screen, tap Use a custom server and enter the same HTTPS origin as WEB_ORIGIN (for example https://app.example.com). Store builds still default to EXPO_PUBLIC_API_URL; the in-app setting is an override for people running their own API. Changing the server signs the device out of any previous session.