Architecture

fctl is a software factory: you describe work, and Claude agents do it in isolated sandboxes on machines you own. This document is the map. Read it before changing anything that crosses a component boundary.

                         ┌──────────────── Cloudflare ────────────────┐
  browser (web app) ───► │  fctl-control  (Rust → wasm Worker)        │
  fctl CLI ────────────► │   ├─ REST API  /api/v1/*                   │
  Claude apps ◄─┐        │   ├─ static web app (Workers assets)       │
                │        │   ├─ D1: users, projects, sessions, …      │
                │        │   ├─ FleetDO: runners, scheduling, feed    │
                │        │   └─ SessionDO (one per session): events,  │
                │        │      viewers, message delivery             │
                │        └──────────────▲─────────────▲───────────────┘
                │                       │ control WS  │ session uplink WS (one per session)
                │        ┌──────────────┴─────────────┴───────────────┐
                │        │  fctl host  (Rust daemon on your machine)  │
                │        │   ├─ drivers: apple · docker · lima · proc │
                │        │   └─ per session: sandbox + attach channel │
                │        └──────────────┬─────────────────────────────┘
                │                       │ framed JSON over exec/unix socket
                │        ┌──────────────┴─────────────────────────────┐
                │        │  sandbox (VM)                               │
                │        │   fctl-agent (supervisor, journal)          │
                └──────── │   └─ claude -p --input/output-format stream-json
       Remote Control    └─────────────────────────────────────────────┘

Components

Path What Runs on
crates/fctl-proto Wire types for every protocol below. TypeScript is generated from it. everywhere
crates/fctl-domain Pure logic: roles and permissions, validation, tokens, scheduling, event shaping. No I/O. everywhere
crates/fctl-control The control plane Worker: HTTP API, auth, Durable Objects, GitHub App client. Cloudflare (wasm32)
crates/fctl-host The host daemon library: control-plane client, session manager, sandbox drivers. macOS, Linux
crates/fctl-agent The supervisor inside each sandbox: runs claude, journals events, prepares the workspace. Linux sandboxes
crates/fctl The fctl binary: CLI for people and agents, plus fctl host …. macOS, Linux
crates/fake-claude A scripted stand-in for claude that speaks stream-json. Used by tests and cargo xtask dev. dev, CI
xtask Development automation (cargo xtask dev, check, gen, deploy, dist, publish). dev, CI
web/ The web app (React, Vite, TypeScript, WebGL). Served by fctl-control. browser
images/agent The default sandbox image (Debian, git, gh, node, python, claude). sandboxes

Two more repositories complete the picture: hyperstructure/design (@hyperstructure/design: tokens, fonts, the WebGL scenes and the marks, used by web/) and hyperstructure/fctl-site (fctl.sh: the landing page, these docs rendered from docs/, the installer and release downloads).

Principles

  1. Subscription only. Agents authenticate with Claude subscription OAuth tokens. API keys are stripped from every sandbox environment, the supervisor refuses to run when Claude reports an API-key credential source, and it interrupts a session the moment Claude reports paid overage use. See Claude credentials.
  2. Your machines do the work. Cloudflare holds state and routes messages. Hosts connect outbound only: no open ports, no tunnels, NAT friendly.
  3. Nothing is lost on a reconnect. Agent events carry a sequence number assigned in the sandbox and are journaled there until the control plane acknowledges them. Messages to agents are delivered at least once and de-duplicated by id.
  4. One owner per secret. The control plane owns credentials and mints short-lived, narrowly scoped tokens for each session. Sandboxes never see refresh tokens or the GitHub App key.
  5. Agent first. Every capability is reachable from the CLI with --json, every component runs locally with one command, and the repository explains itself (AGENTS.md, this file, ADRs).

Identity and access

Data

D1 (crates/fctl-control/migrations) holds relational state: users, identities, allowlist, auth tokens, device codes, projects, members, sessions (summary rows), shares, runners, encrypted credentials and project secrets, and the audit log.

SessionDO (one per session, SQLite storage) holds the event log and the live state of a session: connected viewers, the runner uplink, pending deliveries, and the in-progress streaming message. It writes summary changes through to D1.

FleetDO (one per instance) holds runner control connections, assigns sessions to runners, and fans out session summaries to dashboard viewers.

Sessions

A session is one Claude conversation in one sandbox, on a branch of the project's repository.

queued → provisioning → preparing → running ⇄ idle → stopped
                                       │        │
                                       └─ paused (usage limit) ─┘   failed (any)
  1. POST /api/v1/projects/{id}/sessions creates the row and asks FleetDO to schedule it. Without a free runner it stays queued.
  2. FleetDO builds the SessionSpec just in time (repository, branch, agent settings, secrets, a fresh GitHub installation token, the starter's Claude credential) and sends start_session to a runner.
  3. The host creates a sandbox with its driver, injects fctl-agent, starts it detached, attaches to it, and opens the session uplink to SessionDO.
  4. fctl-agent clones the repository, creates the branch, runs the project's setup script, writes Claude's configuration, and starts claude -p --input-format stream-json --output-format stream-json.
  5. Claude's messages flow back as events; messages from people flow down as deliveries. A finished turn makes the session idle; the sandbox stays up until the idle timeout, then stops. Stopped sessions can be resumed (claude --resume).

Event flow and reliability

Viewers

Browsers and the CLI open GET /api/v1/sessions/{id}/ws. The first frame is a snapshot (summary, recent events, presence, in-progress text); after that frames are incremental. Older history is paged over HTTP.

Claude credentials

Each user connects their own Claude subscription; a session always runs on the subscription of the person who started it. Two kinds are supported:

Kind How it is created Remote Control
Long-lived token claude setup-token on any machine, pasted into fctl no (inference-only scope)
Full login claude auth login run by the official CLI in a sandbox; fctl shows the sign-in link and relays the code yes

Sandboxes receive only an access token (CLAUDE_CODE_OAUTH_TOKEN, plus the granted scopes in CLAUDE_CODE_OAUTH_SCOPES). When it expires, Claude asks its host with the oauth_token_refresh control request; fctl-agent forwards it to the control plane, which refreshes once per user and answers. With a full login, fctl-agent enables Remote Control with the remote_control control request, so the session also appears in claude.ai/code and the Claude apps.

Guards against API billing: ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL, Bedrock/Vertex/Foundry switches and apiKeyHelper never reach Claude; the system/init message must report no API key source; a rate_limit_event showing overage use interrupts the session and pauses it until the limit resets.

Sandboxes and drivers

A driver turns a SandboxSpec (image, CPUs, memory, environment) into a running Linux sandbox and can exec processes in it with piped stdio.

Driver Isolation Host
apple one lightweight VM per session (Apple container) Apple silicon, macOS 26+
lima one full VM per session macOS, Linux
docker containers in Docker/OrbStack/Colima (a VM on macOS) macOS, Linux
process none: a directory and a process development, trusted hosts

fctl-agent runs detached inside the sandbox and listens on a unix socket; the host talks to it through fctl-agent attach over the driver's exec channel. Restarting or upgrading the host does not stop agents.

Protocols

All protocols are JSON with a type tag and live in fctl-proto:

Changing a type means: update fctl-proto, run cargo xtask gen (regenerates web/src/gen), and keep older peers working — hosts and agents may lag the control plane by a version. Add fields as optional; never repurpose a tag.

Deployment

Everything instance-specific (domain, GitHub App, Google client, email) is configuration; see self-hosting.