Architecture
How Controller's client, server, agent CLIs, and Electron shell fit together.
At a high level Controller is three layers:
client (React + Vite) ↔ server (Express) ↔ agent CLIs (child processes)- The client is a React + Vite single-page app (Tailwind CSS, shadcn/ui).
- The server is an Express backend that streams agent events to the client over SSE and backs embedded terminals over WebSocket.
- Agent CLIs (Anita, Codex, Claude) run as child processes the server spawns, parses, and normalizes.
client/ React + Vite frontend (Tailwind CSS, shadcn/ui)
electron/ Electron desktop shell
server/ Express API backend
lib/
agents.ts Agent provider abstraction & Anita/Codex/Claude adapters
auto-approve.ts Per-agent auto-approve flag sets (single source of truth)
sessions.ts Session & event persistence (JSON/JSONL files)
projects.ts Project registry
api-keys.ts Provider API key storage (single- and multi-field providers)
models.ts Model list fetchers per provider (Ollama, Cloudflare, ...)
routes/
sessions.ts SSE streaming, session CRUD
projects.ts Project CRUD
models.ts Model listing
api-keys.ts API key CRUDProvider abstraction
server/lib/agents.ts defines the provider abstraction and the Anita / Codex /
Claude adapters. The server spawns each agent CLI as a child process, parses its
JSON output stream, normalizes events into a common shape (AgentStreamEvent),
and forwards them to the client over SSE. Adding a new provider means adding an
adapter here that maps the CLI's output onto AgentStreamEvent.
Model providers (Anita)
Anita picks the model it talks to from a list of model providers registered
in server/lib/api-keys.ts. The PROVIDERS array is the single source of
truth: it drives the API-key rows in Settings → Agents → Anita → API Keys
(via server/routes/api-keys.ts), the env-var surface injected into the
spawned agent process (getApiKeyEnvVars), and the model-list fetchers in
server/lib/models.ts that populate Anita's default-model picker.
A provider entry is one of two shapes:
- Single-field — one editable value, e.g.
OLLAMA_API_KEY,OPENROUTER_API_KEY. The store, the route, and the UI all collapse to the legacy{ providerId: key }shape. - Multi-field — several named values (Cloudflare today: account id, API
token, AI gateway id). The store carries them as
{ providerId: { fieldId: value } }, the route exposes onePUT/DELETE /api-keys/:providerId/:fieldIdper value, and the UI renders one row per field. Account and gateway ids are non-sensitive identifiers; only the API token is treated as a secret.
server/lib/models.ts ships a fetcher per provider that hits the upstream
/models endpoint and returns a flat list of Model records. Model ids are
prefixed with the provider id (cloudflare/..., ollama-cloud/...) so the
existing groupModelsByProvider helper in the client renders them in the
right <optgroup> without further work. Adding a new provider is a
three-step change: append an entry to PROVIDERS, wire a fetcher in
fetchAnitaFallbackModels (special-case multi-field providers the way
Cloudflare is), and add the matching env-var surface — getApiKeyEnvVars
picks up the new field automatically.
Removed providers (e.g. an old OpenAI key) and orphaned field ids (e.g. a renamed Cloudflare field) are pruned on the next read; the prune persists to disk so the on-disk shape stays consistent with the in-memory view.
Session persistence
server/lib/sessions.ts stores session state and events as JSON/JSONL files
under the Controller home — never inside the project working tree:
${CONTROLLER_HOME}/projects/<basename>-<hash16>/{sessions,events,attachments}/<basename> is the name of the checkout's own directory and <hash16> the
first 16 hex characters of the SHA-256 of its absolute path, so the main
checkout and every feature worktree each get an isolated store. A session's
transcript in a worktree named issue-369 lands at, for example,
${CONTROLLER_HOME}/projects/issue-369-5cb2af9d83d3f77f/events/<sessionId>.jsonl
— the hash is derived from the full absolute path, so the same branch name
checked out elsewhere hashes differently. attachments/ is created lazily, so
a store may hold only sessions/ and events/.
Schedules are the exception: they are project-scoped, not per-checkout. Every
schedule API is called with the project's own path
(server/routes/schedules.ts), so schedule files live in a schedules/
directory inside the project root's store and a worktree's store never holds
any. A schedule that targets a worktree records it in a worktreeId field;
only the session it materializes lands in that worktree's store.
projectStoreDir in server/lib/paths.ts is the source of truth for this
layout — it also records why the old project-local .coding-agent/ directory
was abandoned (it collided with the anita CLI's own session storage and
double-recorded every event). See State location for
how ${CONTROLLER_HOME} itself is resolved.
server/lib/projects.ts holds the project registry and
server/lib/api-keys.ts stores provider API keys. Because the transcript is
an append-only event log on disk, sessions resume with full history and
survive restarts.
Embedded terminals and tmux
Auto-approve
Every agent runs in auto-approve mode by default: it carries out file reads, edits, and commands without pausing for permission. Each agent can be switched to manual approval per agent in Settings → Agents; the choice is persisted and takes effect on the next turn. When auto-approve is off, the agent asks before each action and Controller renders the request as an approval card in the session view (Allow once / Always allow / Deny).
The exact flags each agent uses to opt into (or out of) auto-approval live in
server/lib/auto-approve.ts — the single source of truth that both the CLI
launcher (agents.ts) and the Codex app-server adapter (codex-app-server.ts)
read from. Claude routes manual approvals over its stream-json control channel;
Codex routes them over the app-server's requestApproval JSON-RPC calls. Anita
does not yet expose a structured approval channel, so its manual-approval prompts
are not rendered in the UI yet.
Embedded terminal tabs are backed by deterministic tmux sessions. The Express
server attaches browser WebSocket connections to those sessions, so:
- Commands survive browser refreshes and backend hot reloads (the
tmuxsession keeps running; the browser just re-attaches). - Closing a terminal tab intentionally kills its associated
tmuxsession.
The deterministic session naming is what makes re-attachment and clean teardown reliable.
Electron shell
Electron is an additional shell around the same app. In development it loads the Vite dev server. In packaged builds it starts the compiled Express backend locally and loads the built client from that backend — so browser and desktop modes keep identical API, SSE, and WebSocket contracts.
Integrations gateway
The integrations gateway presents a uniform surface over
three transport kinds: an MCP integration runs as a child process the
server talks to, a REST integration is reached via a request against its
base URL, and a native CLI integration is discovered and invoked on the
host. Credentials are injected by the server at call time, never exposed to the
agent.
Controller CLI and the agent preamble
Controller drives the agents partly through a controller CLI and a system
preamble injected into every agent turn. The preamble tells the agent it's
running inside Controller and surfaces two discovery points without the agent
having to guess: the app-owned unified skill catalog (name + description,
with full bodies fetched on demand) and the enabled integrations gateway.
The agent invokes the CLI by its absolute install path — some providers
sanitize the environment before spawning commands, so the bare controller
command may not resolve on PATH. Delivery is provider-aware: for Anita the
preamble goes through --system-prompt; for Codex and Claude it's prepended to
the user message, since those providers have no reliable system-prompt channel
in their default modes. This CLI surface is for the agent, not for end users —
the UI is the user-facing path. See Skills and
Integrations for the user-facing side.
Scheduler
The scheduler subsystem runs a tick (every 30s by default, overridable with
SCHEDULER_TICK_INTERVAL_MS) and fires due schedules — one-shot or recurring
(cron) — on the next tick after their target time. Schedule state is stored per
project under the Controller home, so schedules survive restarts.
Where state lives
Controller home resolution, platform-specific paths, and the macOS TCC prompts the app surfaces are documented separately:
To build and run all of this yourself, see Building from source.