Controller

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 CRUD

Provider 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 one PUT/DELETE /api-keys/:providerId/:fieldId per 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 tmux session keeps running; the browser just re-attaches).
  • Closing a terminal tab intentionally kills its associated tmux session.

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.

On this page