# fctl > fctl is a software factory for Claude Code. People, scripts and other agents describe work; fctl runs Claude Code agents on it in isolated Linux sandboxes on machines the team owns, around the clock, on the Claude subscriptions of the people who start them, never on API billing. Each piece of work is a session: one Claude conversation in its own sandbox, on its own git branch, usually ending in a pull request that the agent comes back to for review comments, failed checks and conflicts. Everything is reachable from the web app, the `fctl` CLI (every command takes `--json`) and an HTTP API. This file is for agents and the people who set them up: what fctl is, the words it uses, how to drive it safely from a terminal or another program, recipes for common flows, what to avoid, and where the details are. It is served at https://fctl.sh/llms.txt, at `/llms.txt` of every fctl instance (such as https://app.fctl.sh/llms.txt), and kept in the repository as `docs/llms.txt`. Every doc page is also served as Markdown (`https://fctl.sh/docs/.md`), and https://fctl.sh/llms-full.txt is this file followed by all the docs in one file. For flags, `fctl --help` has the final word. fctl is MIT licensed and open source: https://github.com/hyperstructure/fctl. The hosted instance is https://app.fctl.sh (by invitation or early access); anyone can run their own instance on Cloudflare or in one container. ## What fctl gives you - **Unattended agents in sandboxes.** Each session runs Claude Code with permission prompts off inside its own Linux VM or container on a runner (a Mac with Apple's `container`, Lima, or Docker). The agent clones the project's repository, works on a branch named `fctl/-`, commits, and fctl pushes after every turn; it opens a pull request when the task is done, unless the project turns that off. - **Live, steerable work.** Every event is journaled and streamed: follow a session in the web app, in a terminal (`fctl attach`), or in the Claude apps (Remote Control, with a full Claude login). Message the agent at any time (with files), interrupt its turn, stop it, resume it later in the same sandbox and conversation. - **Work that starts itself.** Schedules (cron, once at a time, or only when asked), Linear (assign or mention fctl on an issue), GitHub Actions and any tool that can make an HTTP request with an access token. - **Agents that finish the job.** Follow-ups bring an agent back to the pull request it opened when someone who can write to the repository mentions `@fctl` or `@agent`, a check fails, the pull request conflicts with its base branch, or it merges or closes. Built-in review agents review every new commit of pull requests and hand their findings to the agent that opened the pull request. - **Agents that learn.** A project memory of notes that each session's agent starts with and adds to, and a retrospective of each piece of work for the people who follow along. - **Teams.** Workspaces with people, roles, runners, GitHub accounts, Linear workspaces, an audit log, two-factor sign-in and passkeys; projects with members, settings and secrets; sessions shared with others. - **Subscriptions only.** API keys are stripped from every sandbox, a session must report a subscription credential, and a session pauses before it would draw on paid extra usage. - **Yours to run.** The control plane runs on Cloudflare (a Rust → WebAssembly Worker with Durable Objects and D1) or in one container anywhere (`ghcr.io/hyperstructure/fctl-control`). Hosts dial out; nothing listens on your machines. ## How it fits together ``` web app · fctl CLI · HTTP API · Claude apps · Linear · GitHub webhooks │ control plane (an instance) users, workspaces, projects, sessions' event logs, │ (hosts dial out) scheduling, credentials, GitHub App, Linear app fctl host on a machine (runner) one sandbox per session: apple · lima · docker · process │ fctl-agent + Claude Code in the sandbox: journals events, pushes, reports git state ``` - The **control plane** (one per instance, such as app.fctl.sh) serves the API and the web app, keeps each session's event log, mints short-lived GitHub tokens, hands sessions to runners and relays messages. It never runs Claude itself. - A **host** is the `fctl host` service on a machine; registered with an instance it is a **runner**. It runs each session in a **sandbox** made by its **driver**, and puts **fctl-agent** in it. - **fctl-agent** supervises `claude -p` in stream-json mode, journals every event until the control plane acknowledges it, pushes branches, looks up pull requests, and answers the in-sandbox `fctl` commands (`fctl edit`, `fctl memory`, `fctl ticket`…) without any fctl credentials in the sandbox. ## Vocabulary - **Instance**: one fctl deployment: a control plane and the hosts connected to it. Its URL is what `fctl login` and `FCTL_SERVER` name (`https://app.fctl.sh`, or a self-hosted one). - **Workspace** (`wsp_…`, `/w/<slug>`): the people who work together, their projects, the runners their sessions run on, the GitHub accounts and Linear workspaces they work in, and an audit log. A person is in at most five workspaces (the primary instance owner in any number). - **Project** (`prj_…`, `/p/<slug>`, slugs are unique per instance): a primary GitHub repository (or none), more repositories of the same GitHub account (read only or read and write), and everything agents need: setup script, instructions, model and effort, image, CPUs and memory, environment variables, secrets, idle timeout, GitHub access, follow-ups, ticket tracker, memory, schedules, review agents. - **Session** (`ses_…`, or `<project>#<number>` like `my-app#12`, URL `/s/<id>`): one agent working on one task: one Claude conversation in one sandbox on its own branch. A session has turns, a status, a transcript (its event log), pull requests, and a person it runs for, whose Claude subscription it uses. - **Task**: the first message of a session. **Turn**: one round of Claude's work, from a message to the `result` that ends it. **Message**: what a person or tool sends a session at any time; it reaches the agent at its next step, and a stopped session resumes for it. - **Agent**: Claude Code running in a session's sandbox. **Review agent**: an agent that reviews a pull request's commit and posts findings through fctl. In these docs "agent" means one of these unless it says otherwise. - **Runner** (`rnr_…`): a machine registered with an instance, running `fctl host`. It belongs to the person who registered it and runs the sessions of the workspaces it is added to: everyone's there (added by an admin) or only its owner's (added by a member, or `--personal`). - **Host**: the `fctl host` daemon (and the machine). **Driver**: how a host makes sandboxes: `apple` (a lightweight VM per session, Apple silicon, macOS 26+), `lima` (a full VM), `docker` (a container; on macOS inside Docker's VM), `process` (no isolation, development only). - **Sandbox**: the VM or container a session runs in. Its user is `agent`, the repository is at `~/workspace`, other repositories go in `~/repos/<owner>/<name>`, attachments in `~/attachments/`. A stopped session keeps its sandbox (72 hours by default) so a resume continues the same conversation. - **Image**: what sandboxes start from: `ghcr.io/hyperstructure/sandbox:<version>` by default (Debian with git, gh, Python and uv, Rust, Node.js, TypeScript, Bun, Claude Code and fctl), or the project's own, or one built from a Dockerfile or devcontainer.json in its repository. - **Claude connection** (`crd_…`): a person's Claude subscription credential: a full Claude login (Remote Control works), a long-lived token from `claude setup-token`, or a token kept on their runners that fctl never sees. - **Access token** (`fctl_pat_…`, id `tok_…`): a token for scripts, CI, bots and other agents, optionally limited to reading, running sessions or everything, to some projects or workspaces, and in what its sessions' agents may do on GitHub. - **GitHub access** of a session: `push` (commit, push, open pull requests), `comment` (review, comment, label, resolve review threads; push nothing) or `read` (read only). The most a session gets is what its project and the token that starts it allow. - **Follow-up**: a message from fctl that brings an agent back to a pull request its session opened: `comments`, `checks`, `conflicts`, `closed`. - **Schedule** (`sch_…`, runs `srn_…`): a task fctl starts sessions for on its own: on a cron expression in a time zone, once at a time, or only when asked (by a person or a tool). - **Ticket tracker**: where a project's agents read and write tickets: GitHub Issues (of every repository they write to, or one), a Linear team, or none. In a sandbox, `fctl ticket` reaches it. - **Memory** (`mem_…`): a project's notes for the agents of later sessions. **Retrospective** (`rtr_…`): how a piece of work ended (`completed`, `partial`, `blocked`), what was done, the approach, what got in the way, and what a future agent should know. - **Agent Floor**: the web app's home page: the task box and every active session. - **Remote Control**: Claude Code's link to the Claude apps; sessions on a full Claude login show up in claude.ai/code and the Claude apps of the person they run for. - **Published screenshot**: an image an agent publishes with `fctl publish` at an unguessable link, to show in a pull request (off unless the instance and the project allow it). - **Drain**: a runner that drains takes no new sessions while the ones it has carry on. ## Roles - **Instance**: `owner` (the primary instance owner, one), `admin` (people, invitations, allowlist, runners, GitHub installations, instance settings; not inside workspaces they aren't in), `member`. - **Workspace**: the primary admin (one; hands the role over before leaving), `admin` (people, roles, runners, GitHub and Linear connections, settings; creates projects; owns every project), `member`. A **base role** (`none`, `viewer`, `editor`, `maintainer`; new workspaces: `editor`) gives every member that role in every project. - **Project**: `owner` (everything, including deleting it and turning review agents on), `maintainer` (settings, secrets, members, schedules of others), `editor` (start sessions, message agents, keep memory), `viewer` (read sessions). A member's role is the higher of their project role and the base role. - **Session**: `owner` (who started it, while they may still start sessions in the project, and the project's maintainers and owners: stop, resume, archive, share), `editor` (message and interrupt), `viewer` (read). Sessions can be shared with people in the same workspace, as viewers, editors or owners. - **Access token**: `read` (like a viewer), `run` (like an editor, and it stops and resumes the sessions it started), `full` (whatever its person may in its projects). A limited token never reaches the account, workspaces' people and settings, Claude connections, other tokens or administration. ## Session lifecycle ``` queued → provisioning → preparing → running ⇄ idle → stopping → stopped │ failed (any time) └→ paused (usage limit; resumes on its own) ``` - `queued`: waiting for a runner of its workspace that is online, has a free slot, runs its image and size, and runs this person's sessions. `status_reason` says why it waits ("Waiting for a runner: …"): a busy runner frees up by itself, but no runner online, or none that allows its image or size, takes a person. - `provisioning` → `preparing`: the host makes the sandbox; the agent clones the repository, creates the branch and runs the project's setup script. - `running`: a turn is under way. `idle`: the turn ended and the agent waits for a message (`status_reason` "Waiting for a message"), or for its background tasks ("Waiting for 2 background tasks"), whose results Claude takes in a turn of its own. - An idle session stops after the project's idle timeout (10 minutes unless set; `fctl projects set --idle-timeout`). Background work keeps it up for at most an hour after its last turn. `stopped` keeps the sandbox: a message to a stopped session resumes it on the same runner, with Claude's conversation and anything uncommitted; if that runner is gone or busy for 10 minutes, it resumes on another runner in a new sandbox from the pushed branch, with a new conversation. - `paused`: the person's plan hit a usage limit (or the turn would have drawn on paid extra usage); it continues by itself when the limit resets (`resume_at`). - `failed`: something went wrong (`status_reason` says what). Only a resume (`fctl resume`, on the resumer's subscription) restarts a failed session; a message doesn't. - A runner that goes away leaves its agents working; after ten minutes the session shows as stopped and a message resumes it elsewhere, unless the runner comes back first. - Review agents' sessions take no messages, stop as soon as their review is posted, and stay off the Agent Floor and session lists. ## How agents work in a session - **Instructions.** Each agent gets fctl's system prompt (the repository and branch, other repositories, sandbox size, how to commit and open pull requests, the project's pull request description style, tracker rules, then the project's instructions) and a `~/AGENTS.md` (imported by `~/.claude/CLAUDE.md`) with the project's memory and how to keep it, how to name the session and record retrospectives. The repository's own `CLAUDE.md`/`AGENTS.md` apply as usual. - **Git and pull requests.** Commits are authored by the instance's GitHub App bot, with `Co-authored-by` trailers for Claude and for the person who started the session (if they set a commit email; projects can turn either off). fctl pushes the session branch after every turn until its pull request merges; agents never force-push or rewrite pushed history. In other repositories they change, agents use the same branch and open a pull request in each. After a merge fctl stops pushing that branch: further work goes in a new branch and pull request. Session pages and `fctl show` list every pull request with its state (`open`, `draft`, `queued`, `merged`, `closed`) and its checks. - **GitHub tokens.** Installation tokens for the project's repositories only, valid an hour and refreshed through the host: one that writes as far as the session's GitHub access allows, one that only reads. git and `gh` are signed in. Changes to a project's repositories or GitHub access reach running sessions within the hour. - **Follow-ups** (webhook needed; new projects have all four on): a comment, review or line comment that mentions `@fctl` or `@agent` (or starts with `fctl:`/`agent:`) from someone who can write to the repository; a failed check of the newest commit (at most three in a row while nobody messages the agent); a conflict with the base branch, which the agent resolves by merging the base branch in (never rebasing), running the tests and pushing; a merge or close. Each arrives as a message from fctl that resumes the session on the subscription it ran on, and the agent answers in the threads on GitHub. - **Review agents** (per project, on an owner's subscription): each new commit of a pull request in the repositories the project writes to gets a review: an `eyes` reaction, an `fctl review` check, a summary comment and findings on the lines. Pull requests from people who can't write to the repository, and from apps other than the ones the owners chose (Dependabot by default), are reviewed only when someone asks (`fctl review start`). When an fctl agent opened the pull request, its session gets the findings to fix or answer (at most five reviews in a row while no person messages it). - **In-sandbox commands** (through fctl-agent, no sign-in): `fctl edit --title … --description …` (name the session), `fctl memory` / `fctl memory add|stale|duplicate`, `fctl retro` / `fctl retro update|list`, `fctl ticket …`, `fctl publish FILE` (screenshots for pull requests; `gh pr edit --attach` fails with the app's token), `fctl resolve PRRT_…` (resolve review threads, for agents that comment), and for review agents `fctl review threads` and `fctl review submit FILE`. - **What a sandbox has.** The project's environment variables and secrets, `CLAUDE_CODE_OAUTH_TOKEN`, `FCTL_SESSION_ID`, `FCTL_SESSION_URL`, `FCTL_SANDBOX_CPUS`, `FCTL_SANDBOX_MEMORY_MB`, outbound network, sudo. No fctl credentials, no API keys, no GitHub App key. ## Claude credentials and usage - Each person connects their own subscription (Pro or Max) in Settings → Claude or with `fctl claude connect --login` (full login through a host; Remote Control and claude.ai connectors), `fctl claude connect` (paste a `claude setup-token` token; lasts a year), or `fctl claude connect --on-runners` after `fctl host claude set-token` on each of their runners (the token never reaches fctl; their sessions then run only on those runners). - A session runs on the connection of the person it runs for: who started it (or resumed it last; a schedule's owner; whoever delegated a Linear ticket; the access token's person). A member chooses which connection each workspace's sessions use (`fctl workspaces claude <connection> -w <workspace>`). - `fctl claude status` shows each connection's plan usage (five-hour window, week, per-model week) as sessions last reported it. Sessions pause at a limit and resume when it resets. ## Driving fctl from an agent or a script Install and sign in: ```sh curl -fsSL https://fctl.sh/install | sh # or: brew install hyperstructure/tap/fctl export FCTL_SERVER=https://app.fctl.sh # the instance export FCTL_TOKEN=fctl_pat_… # an access token a person made for this fctl whoami # who it acts as, and what the token may do ``` - Prefer an access token limited to the job over a person's sign-in: `fctl token "Nightly triage" --access run -p my-app --github comment --days 30` (a person runs it; it prints the token once). `fctl login` needs a person to approve the device in a browser (`--no-browser` prints the link). - Name sessions by id (`ses_…`, also in URLs) in scripts: `my-app#12` only finds the project's latest 200 sessions. - `--json` prints one JSON document on stdout, in the API's shapes (`fctl run -d --json` prints the new session, `.id` is its id). `fctl logs --json` is the exception: one event per line (JSON Lines), with or without `-f`; use `jq -s` to read it as an array. - Exit codes: 0 success; 1 an error (the message on stderr, as text even with `--json`); 2 a usage error (unknown flag, missing argument). Check the exit code; don't parse stderr. - Commands that take a project use `-p`, else the default from `fctl projects use`. Commands that take a workspace use `-w`, else `fctl workspaces use`, else your only one. - A turn is over when a new `result` event appears in the transcript. Right after a message the status can still say `idle`, before the agent takes it, so count `result` events rather than watch the status. A `failed` session takes no turn until someone resumes it, and a `paused` one until its person's usage limit resets. The core loop: ```sh id=$(fctl run -p my-app -d --json "Fix the flaky login test and open a pull request" | jq -r .id) turns() { fctl logs "$1" --json | jq -s '[.[] | select(.kind == "claude" and .data.type == "result")] | length'; } wait_turn() { # waits until the session has more than $2 finished turns, or can't go on while [ "$(turns "$1")" -le "$2" ]; do case $(fctl show "$1" --json | jq -r .status) in failed|paused) return 1 ;; esac sleep 20 done } wait_turn "$id" 0 fctl logs "$id" --json | jq -s -r '[.[] | select(.kind == "claude" and .data.type == "result")] | last | .data.result' fctl show "$id" --json | jq '{status, status_reason, branch, pull_requests}' n=$(turns "$id"); fctl send "$id" "Also add a regression test"; wait_turn "$id" "$n" ``` A session that stays `queued` says why in `status_reason` (no runner online, no runner allows its image, its person's runners are offline…): report it rather than wait forever. ## Things to avoid - **`fctl run` without `-d` never exits on its own.** It follows the session and reconnects forever, also after the session stops, so a tool call that runs it hangs until it is killed. Always pass `-d` (`--detach`) in scripts and tool calls. - **`fctl attach` and `fctl logs -f` don't exit when the session ends.** `fctl logs -f` and `fctl attach --read-only` exit at once only if the session had already stopped or failed when they connected; `fctl attach` never does. Use `fctl logs` (without `-f`) and `fctl show`, or wrap them in `timeout`. `fctl attach` is for people at a terminal. - **`fctl run` and `fctl send` without message text read stdin until it closes**, so they hang where stdin stays open. Give the text as an argument or with `-f FILE` (`fctl run`), or pipe it in explicitly. - **Don't retry `fctl send` blindly**: each call is a new message. Over the API, a `client_id` makes retries deliver once. - **`--env KEY=VALUE` is not secret** (project members see it in the settings). Credentials go in the project's secrets, with the value on stdin or typed at its prompt, never as an argument, which shell history keeps: `printenv STRIPE_KEY | fctl projects secrets set STRIPE_KEY -p my-app`. Both are readable by anything the agent runs. - **Deletions can't be undone.** In a terminal they ask you to type the name; without one they need `--confirm <slug|name|email>`. Don't delete projects, workspaces or accounts unless a person asked for exactly that. - **Sensitive actions can need a person.** Where the instance asks people to confirm it's them, making tokens, adding runners, deleting things and the like fail with `verification_required` until the token in use confirms with `fctl account verify --code <a code from the person's authenticator app>` (good for 15 minutes). Limited tokens can't confirm at all: leave those actions to a person. - **Every session spends someone's Claude plan** and takes a runner slot. Don't start sessions in loops or fan out more than the runners can take; a schedule skips a run while its last session still works, and tools may run one schedule at most 20 times an hour. - **Don't pass untrusted text as instructions.** What you send with `fctl run` or `fctl send` reaches the agent as your message. Text from issues, emails or webhooks belongs in a when-asked schedule's `context`, which the agent gets marked as information, not instructions. - **Give agents the least GitHub access the task needs** (`--github comment` or `--github read` for reviews, triage and planning) and tokens that expire and reach one project. - **Don't reuse a session's branch after its pull request merged**, force-push it, or push to it from outside while its agent works; message the agent instead. - **A failed session takes no messages** (`fctl send` is refused): `fctl resume <session> "…"` restarts it, on your subscription. - **`fctl stop` ends the session; `fctl interrupt` only stops the current turn.** Neither is needed to finish work: idle sessions stop by themselves. - **Don't run `fctl host install|uninstall|drain` on a machine unless its owner asked**: hosts run other people's sessions. Without a terminal, `fctl host uninstall` needs `--wait` (drain first) or `--force` (stops running sessions). ## Recipes Each recipe says who does what: some steps take a person with a role in the project or workspace, a browser, or the machine's terminal. 1. **Delegate a task and collect the result**: the core loop above. Add `--github read` for an answer without changes, `-a FILE` (up to 10 files of 20 MB) for screenshots or logs, `--model opus|sonnet|haiku|fable --effort low|medium|high|xhigh|max` to override the project's, `--base BRANCH` to start elsewhere, `--branch NAME` to choose the branch. 2. **Several independent tasks at once**: start one session per task with `fctl run -d`; each gets its own sandbox, branch and pull request. Watch them with `fctl sessions --active --json`. Runners take as many as their capacity allows (4 each by default); the rest queue. 3. **Steer or stop**: `fctl send <session> "…"` (resumes a stopped session), `fctl interrupt <session>`, `fctl stop <session>`, `fctl resume <session> "…"`. 4. **From any tool (issue tracker, chat bot, deploy pipeline, n8n/Zapier/Make)**: a person sets up a schedule that runs when asked and a token that runs sessions in that project, then the tool posts what it knows: ```sh fctl schedules add "Triage an issue" -p my-app --when-asked --github comment \ -m "Triage the issue that comes with this: label it, say what is missing, link duplicates." fctl token "Issue triage" --access run -p my-app --github comment --days 90 curl -H "Authorization: Bearer $FCTL_TOKEN" -H "Content-Type: application/json" \ -d '{"context": "Issue #41: The login page hangs on Safari…"}' \ "$FCTL_SERVER/api/v1/schedules/sch_…/runs" # context: at most 20,000 characters ``` The answer is the run, with the session it started (`.session.id`). From a shell: `fctl schedules run sch_… --context-file -`. 5. **Recurring work**: `fctl schedules add "Weekly dependencies" -p my-app --cron "0 9 * * 1" --tz Europe/Stockholm -m "Update the dependencies with patch or minor releases, run the tests and open a pull request."` Once: `--at 2h` or `--at 2026-10-30T16:00+01:00`. Five-field cron or `@daily`/`@weekly`…, at most every 15 minutes, `--tz` defaults to the machine's zone (set it in CI), `--paused` to set one up stopped. Runs go on the schedule owner's subscription; `fctl schedules runs sch_…` says what each run started or why not. 6. **Review pull requests**: a project owner turns on review agents: `fctl review settings -p my-app --on --model opus --effort high` (instructions: `--instructions-file review.md`; apps whose pull requests start reviews: `--bots dependabot,renovate`). Ask for one now: `fctl review start -p my-app 12`. Reviews that run tests or start on a label: a GitHub Actions workflow with a run token ([Automation](https://fctl.sh/docs/automation.md)). 7. **Agents that fix their own pull requests**: `fctl projects set -p my-app --follow-ups all` (the default for new projects); then anyone who can write to the repository comments `@fctl please split this function` on the pull request, and failed checks and conflicts come back to the agent on their own. Needs the instance's GitHub webhook. 8. **Linear**: a workspace admin who is a Linear admin runs `fctl workspaces linear connect`; a maintainer picks the team (`fctl projects set -p my-app --tickets linear --linear-team ENG`, optionally `--linear-project "Mobile app"`); each person links their Linear account once (`fctl account linear connect`). Then assigning an issue to fctl (it becomes the delegate) or mentioning fctl starts a session as that person, moves the issue to In Progress, names the branch after the issue key, and posts the agent's steps, pull requests and replies in the issue; replies there reach the agent. The `explore` label asks for a plan first. 9. **GitHub Issues as the tracker**: the default; agents use `gh issue` in the repositories they write to. `fctl projects set -p my-app --tickets github --tickets-repo owner/name` keeps them to one repository's issues (through `fctl ticket` when they write to other repositories too); `--tickets none` keeps them away from trackers. A maintainer can start a session that reaches GitHub's issues whatever the tracker says, to move them into Linear, say (`fctl run --github-issues`). 10. **Plan or review without changes**: `fctl run -p my-app -d --github read "Plan the move to Postgres: what changes, in what order, what can break."` or a project-wide cap: `fctl projects set -p my-app --github comment`. 11. **Set up a project** (a workspace admin): `fctl projects create "My app" --repo my-org/my-app -w acme`, then `fctl projects set -p my-app --setup-script 'npm ci' --idle-timeout 30 --pr-descriptions balanced`, more repositories with `fctl projects repos add my-org/my-lib --write -p my-app`, an image with `--image ghcr.io/acme/agent:1` or `--build Dockerfile`, size with `--cpus 8 --memory 16g`, shared package caches with `--package-caches on`, its agents' instructions, model and effort with `--instructions-file agents.md --model opus --effort high`, opening pull requests when they finish with `--auto-pull-request on|off`, Remote Control and claude.ai connectors with `--remote-control on|off --connectors on|off`, and its name, description and default branch with `--name "Web app" --description "The storefront" --default-branch develop`. Secrets: `fctl projects secrets set STRIPE_KEY -p my-app`, the value typed at its prompt or piped in; `fctl projects secrets -p my-app` lists their names. People: `fctl projects members add grace@example.com --role maintainer -p my-app` (`viewer`, `editor`, `maintainer`, `owner`), `fctl projects members role …`, `fctl projects members remove …`. 12. **Bring a machine online** (its owner, at its terminal): install Apple's `container` (macOS 26+, `container system start`), Lima, or Docker; then `fctl login`, `fctl host install --workspace acme` (or `--token fctl_rt_…` from the workspace's Runners tab), `fctl host doctor`, `fctl host status`. Maintenance: `fctl host drain --wait`, `fctl host undrain`. 13. **Dashboards and reports**: a token with `--access read` lists sessions, transcripts, memory and retrospectives and starts nothing: `fctl sessions --active --json`, `fctl retro list -p my-app --json`, `fctl search "flaky test" --json`, `fctl logs my-app#12 --json`. 14. **Keep agents informed**: `fctl memory add -p my-app "Deploys happen on Thursdays: merge migrations by Tuesday."`; read what they learned with `fctl memory -p my-app` and `fctl retro list -p my-app --outcome blocked`. 15. **Hand a session to someone** (its owners): `fctl share <session> grace@example.com` lets someone in its workspace view it and message its agent (`--role viewer` only views it, `--role owner` also stops, resumes, archives and shares it), `fctl share <session>` lists who it is shared with, `fctl unshare <session> grace@example.com` takes it back. Anyone who can message the agent can have it hand over the sandbox's secrets and tokens. `fctl archive <session>` takes a finished session out of the lists and stops follow-ups reaching it (`fctl unarchive`, `fctl sessions --archived`). ## Web app - **Agent Floor** (`/`): the task box (project, model, effort, GitHub access, files; ⌘Enter starts, **Schedule** turns the task into a schedule), sessions **Working now** and **Recently**, plan usage, invitations. ⌘K searches sessions, projects, workspaces and what sessions said. - **Session page** (`/s/<id>`): the live transcript (tool calls fold into lines), the composer to message the agent, Interrupt, Stop and Resume, Share, the follow bell, Changes (the branch's diff), pull requests with checks, context and usage, retrospectives, ⌘F to find in the transcript, Open in Claude. - **Project page** (`/p/<slug>`): Sessions, Tickets, Schedules, Reviews, Memory, Retrospectives, Repositories and Members tabs; maintainers and owners also see Settings (agents, sandbox, GitHub, follow-ups…) and Secrets. - **Workspace page** (`/w/<slug>`): Projects, People, Runners (with the command and token that add one), GitHub, Linear, Settings, and the Audit log for admins. - **Settings** (`/settings`): profile, email addresses and commit emails, workspaces (which Claude connection, only my runners), Claude, notifications, Linear account, sign-in methods, passkeys, two-factor sign-in, devices and access tokens, appearance, your data, deleting your account. **Admin** (`/admin`, instance admins): people, access (allowlist), early access, workspaces, GitHub, runners, settings, audit. - Notifications are Web Push to browsers where people turned them on (on iPhone and iPad, fctl added to the Home Screen): a followed session waits for a reply, pauses on a usage limit, or fails. ## Docs - [Overview](https://fctl.sh/docs/index.md): the three pieces (control plane, hosts, clients), where to go next, and the words the docs use. - [Getting started](https://fctl.sh/docs/getting-started.md): signing in, installing the CLI, workspaces, connecting Claude, bringing a machine online, creating a project, starting, following and steering sessions, working together, roles. - [The CLI](https://fctl.sh/docs/cli.md): every command: sign-in and access tokens, sessions and sharing them, search, workspaces, projects with their secrets and members, repositories, ticket trackers, memory and retrospectives, review agents, schedules, notifications, Claude, hosts and `host.toml`, admin, your account, updating, exit codes. - [fctl for agents](https://fctl.sh/docs/agents.md): driving fctl from agents, scripts and other programs: tokens, waiting for sessions, reading transcripts, JSON and exit codes, pitfalls, recipes in full, and what agents do inside a session's sandbox. - [The HTTP API](https://fctl.sh/docs/api.md): authentication, errors, paging, and the endpoints for sessions, messages, events, schedules, projects, memory, retrospectives and reviews, with examples. - [Automation](https://fctl.sh/docs/automation.md): schedules, Linear delegation, starting agents from other tools, access tokens, GitHub access levels, review agents from GitHub Actions, other uses. - [Self-hosting](https://fctl.sh/docs/self-hosting.md): the control plane in a container (Docker, Kubernetes) or on Cloudflare, settings, HTTPS, the first account, the GitHub App (permissions, webhook, follow-ups), the Linear app, Google sign-in, email, hosts. - [Sandbox images](https://fctl.sh/docs/sandbox-images.md): the default image and its layout, a project's own image or one built from its repository, which images a host runs, what an image needs, what fctl writes into a sandbox. - [Architecture](https://fctl.sh/docs/architecture.md): how every piece works and talks: identity, workspaces, sessions, GitHub, review agents, follow-ups, schedules, trackers, Linear, events, credentials, sandboxes, protocols, deployment. - [Security](https://fctl.sh/docs/security.md): what fctl protects and how, what sandboxes can reach, what tokens carry, billing guards, hosts, retention. - [Development](https://fctl.sh/docs/development.md): working on fctl itself: the local stack with fake-claude, checks, the repository map. ## Topics in depth - [Session lifecycle and idle sessions](https://fctl.sh/docs/architecture.md#idle-sessions): idle timeout, background work, resumes on the same or another runner, overlapping runs. - [GitHub access](https://fctl.sh/docs/architecture.md#github-access) and [repositories](https://fctl.sh/docs/architecture.md#repositories): tokens per session, push/comment/read, other repositories, how branches and pull requests are followed. - [Pull request state and checks](https://fctl.sh/docs/architecture.md#pull-request-state-and-checks): the GitHub webhook and lookups. - [Follow-ups](https://fctl.sh/docs/architecture.md#follow-ups): comments, failed checks, conflicts, merges; limits and what the agent is told. - [Review agents](https://fctl.sh/docs/architecture.md#review-agents): what starts a review, what the agent may do, how findings are posted and passed on. - [Schedules](https://fctl.sh/docs/architecture.md#schedules): cron and time zones, skipped runs, runs from tools, whose subscription. - [Ticket trackers](https://fctl.sh/docs/architecture.md#ticket-trackers) and [Linear](https://fctl.sh/docs/architecture.md#linear): `fctl ticket`, rate limits, delegation, what goes back to Linear. More trackers (Jira, Shortcut, ClickUp) are on the roadmap, on the same `fctl ticket` model. - [Project memory](https://fctl.sh/docs/architecture.md#project-memory) and [retrospectives](https://fctl.sh/docs/architecture.md#retrospectives). - [Access tokens](https://fctl.sh/docs/architecture.md#access-tokens): limits, what a limited token reaches. - [Workspaces](https://fctl.sh/docs/architecture.md#workspaces), [GitHub accounts](https://fctl.sh/docs/architecture.md#github-accounts) and [runners and workspaces](https://fctl.sh/docs/architecture.md#runners-and-workspaces). - [Claude credentials](https://fctl.sh/docs/architecture.md#claude-credentials), [tokens on your runners](https://fctl.sh/docs/architecture.md#tokens-on-your-runners) and [plan usage](https://fctl.sh/docs/architecture.md#plan-usage). - [Sandboxes and drivers](https://fctl.sh/docs/architecture.md#sandboxes-and-drivers), [package caches](https://fctl.sh/docs/architecture.md#package-caches), [draining a runner](https://fctl.sh/docs/architecture.md#draining-a-runner), [host updates](https://fctl.sh/docs/architecture.md#host-updates). - [Event flow and reliability](https://fctl.sh/docs/architecture.md#event-flow-and-reliability) and [viewers](https://fctl.sh/docs/architecture.md#viewers): journaling, replay, the live socket. - [Model and effort](https://fctl.sh/docs/architecture.md#model-and-effort), [commits](https://fctl.sh/docs/architecture.md#commits), [pull request descriptions](https://fctl.sh/docs/architecture.md#pull-request-descriptions). - [Sandboxes' security](https://fctl.sh/docs/security.md#sandboxes): what an agent can reach, and why project secrets and Claude tokens are readable by what it runs. ## Source - [Repository](https://github.com/hyperstructure/fctl): Rust (control plane, host, agent, CLI) and a React web app. Raw files: `https://raw.githubusercontent.com/hyperstructure/fctl/main/<path>`. - [AGENTS.md](https://github.com/hyperstructure/fctl/blob/main/AGENTS.md): how to work on fctl itself: commands, rules, conventions. - [Decision records](https://github.com/hyperstructure/fctl/blob/main/docs/adr/README.md): why fctl works the way it does, one decision each. - [Roadmap](https://github.com/hyperstructure/fctl/blob/main/ROADMAP.md) and [changelog](https://github.com/hyperstructure/fctl/blob/main/CHANGELOG.md). - [API types](https://github.com/hyperstructure/fctl/blob/main/crates/fctl-proto/src/api.rs): every request and response body of the HTTP API, with [sessions](https://github.com/hyperstructure/fctl/blob/main/crates/fctl-proto/src/session.rs) and [events](https://github.com/hyperstructure/fctl/blob/main/crates/fctl-proto/src/event.rs). - [CLI source](https://github.com/hyperstructure/fctl/tree/main/crates/fctl/src/cmd): one file per command group. ## Optional - [Everything in one file](https://fctl.sh/llms-full.txt): this file followed by every doc page. - [hyperstructure/sandbox](https://github.com/hyperstructure/sandbox): the default sandbox image. - [hyperstructure/fctl-site](https://github.com/hyperstructure/fctl-site): fctl.sh, the installer and release downloads. - [hyperstructure/design](https://github.com/hyperstructure/design): the design system the web app uses. - [hyperstructure/homebrew-tap](https://github.com/hyperstructure/homebrew-tap): `brew install hyperstructure/tap/fctl`. - [Security policy](https://github.com/hyperstructure/fctl/blob/main/SECURITY.md): reporting a vulnerability. --- Source: https://fctl.sh/docs/index.md # fctl documentation fctl is a software factory for Claude Code. You describe work; Claude agents do it in sandboxes on machines you own, around the clock, on your Claude subscription. You follow along live from the web app, your terminal or the Claude apps, and anyone you share a project with can steer the agents too. Three pieces make up a factory: - **The control plane** — a Worker with Durable Objects and D1 that serves the web app and the API, keeps every session's event log, and hands work to hosts. One per team (yours might be `app.fctl.sh`). It runs on Cloudflare, or in one container on any machine or Kubernetes cluster. - **Hosts** — your machines (a spare Mac is ideal). `fctl host` runs as a service, connects out to the control plane, and runs each session in its own Linux VM. - **Clients** — the web app, the `fctl` CLI, and the Claude apps on your phone and desktop through Remote Control. ## Where to go next | If you want to… | Read | |---|---| | Start using fctl on an instance someone set up | [Getting started](https://fctl.sh/docs/getting-started.md) | | Drive everything from a terminal or a script | [The CLI](https://fctl.sh/docs/cli.md) | | Hand work to fctl from an agent or a program, and wait for it | [fctl for agents](https://fctl.sh/docs/agents.md) | | Call fctl's API from your own tools | [The HTTP API](https://fctl.sh/docs/api.md) | | Run agents from CI or a bot, such as review agents on your pull requests | [Automation](https://fctl.sh/docs/automation.md) | | Run your own instance, on a machine of yours or on Cloudflare | [Self-hosting](https://fctl.sh/docs/self-hosting.md) | | Know what sessions run in, give a project its own image, or choose the images a host runs | [Sandbox images](https://fctl.sh/docs/sandbox-images.md) | | Understand how the pieces talk to each other | [Architecture](https://fctl.sh/docs/architecture.md) | | Know what fctl does with your credentials | [Security](https://fctl.sh/docs/security.md) | | Work on fctl itself | [Development](https://fctl.sh/docs/development.md) | Agents find all of this summed up in [llms.txt](https://github.com/hyperstructure/fctl/blob/main/docs/llms.txt), at https://fctl.sh/llms.txt and at `/llms.txt` of every instance: what fctl is, its words, how to drive it safely and where the details are. Each page here is also at `https://fctl.sh/docs/<page>.md` as Markdown, and all of them are in https://fctl.sh/llms-full.txt. ## Words used in these docs - **Project** — a repository plus what agents need to know to work in it (setup script, instructions, model, secrets). Projects have members. - **Session** — one agent working on one task: one Claude conversation in one sandbox, on its own branch. Sessions are numbered per project (`my-app#12`). Its agent pushes, comments or only reads on GitHub. - **Access token** — a token for scripts, CI and bots, which can be limited to reading or running sessions, to some projects, and in what its sessions' agents may do on GitHub. - **Memory** — a project's notes for its agents: what earlier agents and people learned that the repository doesn't say. Each session's agent starts with them. - **Retrospective** — what an agent records as it finishes a piece of work: how it ended, what it worked on, the approach it took, what got in the way and what a future agent should know. One for each piece of work: the agent updates it as later turns carry on with that work. - **Runner** — a host registered with the control plane. - **Agent Floor** — the web app's home page, where it opens: the box for your next task, and every active session as a thread of light. --- Source: https://fctl.sh/docs/getting-started.md # Getting started This guide takes you from nothing to an agent working on your code. It assumes an instance exists (if you are setting one up, start with [self-hosting](https://fctl.sh/docs/self-hosting.md) and come back). ## 1. Sign in Open your instance (for example `https://app.fctl.sh`) and sign in with the email and password your invite set up, or with Google if your instance has it. Access is by allowlist: if you cannot sign up, ask an admin to allow your email. Have more than one address, say a private one and your company's? An instance admin invites each on its own. Open the second invitation while you are signed in and choose **Connect it to my account**: both addresses then sign in to the same account, each with its own commit email (**Settings → Email addresses**), and the workspaces you join as one of them see only that one. Add a passkey in **Settings → Passkeys**, and your fingerprint, face or screen lock signs you in from then on, without a password: the browser offers it in the email field. Turn on two-factor sign-in in **Settings → Two-factor sign-in**, so that a password alone can't sign anyone in: scan the QR code with an authenticator app (1Password, Google Authenticator, the passwords app on your phone) and keep the recovery codes it gives you. If your instance requires two-factor, it asks you to add a passkey or set up an app when you first sign in. Where the instance asks for it, sensitive actions, such as deleting a project or making an access token, take your passkey or a code too, at most once every 15 minutes in each browser. The app installs on your phone and computer and then opens in its own window, from the home screen or the Dock: in Safari on an iPhone or iPad, choose **Share → Add to Home Screen**; in Safari on a Mac, **File → Add to Dock**; in Chrome or Edge, the install button in the address bar (on Android, **⋮ → Add to home screen**). To hear when a session waits for your reply, pauses on a usage limit or fails, turn on notifications in **Settings → Notifications**, in each browser you want them in (on an iPhone or iPad, in the app on the Home Screen). You follow the sessions you start; the bell on a session's page follows others. ## 2. Install the CLI ```sh curl -fsSL https://fctl.sh/install | sh ``` The installer puts `fctl` in `~/.local/bin` after checking its SHA-256 and sets up tab completion for your shell; `fctl self update` keeps it current. Or install it with [Homebrew](https://brew.sh) (macOS and Linux): ```sh brew install hyperstructure/tap/fctl ``` The formula installs the same release archive, checked against its SHA-256, and sets up completions. Update with `brew upgrade fctl` instead of `fctl self update`; use one install method per machine. Then sign in from the terminal; it opens your browser to approve the device: ```sh fctl login app.fctl.sh fctl whoami ``` ## 3. Create or join a workspace Projects, the machines that run their agents and the GitHub accounts they work in belong to workspaces: one for your company's repositories, one for your own, one for a side project with friends. An invitation to a workspace shows on your Agent Floor, where you join it; or choose **New workspace** in the sidebar (`fctl workspaces create "Side project"`) and be its primary admin. Each workspace has its own people, admins, runners, GitHub accounts and audit log, and can require two-factor sign-in of its members. You can be in up to five, and the primary instance owner in any number. Until a new workspace has them, its page asks its admins for people, a runner, a GitHub account and a project; in a workspace for one, **Skip for now** leaves out the people. Drag workspaces, and the projects in each, into the order you want in the sidebar (Alt or Option with ↑ or ↓ moves the focused one): the order follows you to your other devices and to `fctl workspaces` and `fctl projects list`. ## 4. Connect your Claude subscription Sessions you start run on your own Claude Pro or Max plan. fctl never uses API keys: it refuses them, checks that every session reports a subscription credential, and pauses a session before it would draw on paid extra usage. There are three ways to connect, all under **Settings → Claude** in the app: - **Sign in with Claude** (recommended). The official Claude Code sign-in runs in a sandbox on one of your hosts; fctl shows you the link and relays the code. This kind of login lets sessions join **Remote Control**, so they show up in the Claude apps. - **A long-lived token.** Run `claude setup-token` on any machine with Claude Code and paste the `sk-ant-oat01-…` token. It is valid for a year, but it cannot use Remote Control. - **A token on your runners**, which you never give to fctl. On each of your runners ([step 5](https://fctl.sh/docs/getting-started.md#5-bring-a-machine-online)), make a token and keep it there: ```sh claude setup-token # there, or on any machine with Claude Code fctl host claude set-token # paste it; kept in ~/.config/fctl/claude-token, mode 0600 fctl host claude status # whether this runner keeps one, and whether fctl knows ``` Then choose **Keep the token on your runners** in Settings → Claude, which shows each of your runners as it reports whether it keeps the token. Your sessions on it run only on your runners that keep it, in every workspace, and wait while none of them is online. Like any token from `claude setup-token`, it lasts a year and can't use Remote Control. From the terminal: ```sh fctl claude connect --login # sign in with Claude through a host fctl claude connect # or paste a long-lived token fctl claude connect --on-runners # or use the token your runners keep fctl claude status ``` Once a session runs on it, the connection shows how much of your plan's limits is used: the five-hour window, the week, and a model's own weekly limit when Claude reports one, each with when it resets. You see it under each connection in Settings → Claude, under the task box on the Agent Floor (for the connection new sessions use), and in `fctl claude status`. Sessions report it as they work, so it is as recent as your latest session. From 80 % of a limit the Agent Floor says how much of it is used, in red from 90 %; sessions pause when one is used up, until it resets. With more than one connection, say your own plan and a seat your company pays for, choose which one each workspace's sessions run on in **Settings → Workspaces** (`fctl workspaces claude <connection> -w acme`, with the name or id `fctl claude status` shows). ## 5. Bring a machine online A workspace's sessions run only on its runners: until one is online, they wait in the queue, and the app says so under the task box. Its **Runners** tab shows how to add a runner, under the ones it has. A runner its admins add runs everyone's sessions; one a member adds runs only theirs. Any Mac with Apple silicon (or a Linux box) can be a host. On the Mac: 1. Install Apple's container runtime from [github.com/apple/container/releases](https://github.com/apple/container/releases) (macOS 26 or later), then start it once: ```sh container system start ``` On older macOS, install [Lima](https://lima-vm.io) (`brew install lima`) instead; on Linux, Docker. 2. Register the machine for a workspace you are in, and start the host service (or pass `--token` with a runner token from the workspace's **Runners** tab). As one of its admins you register it for everyone's sessions there (`--personal`: only yours); as a member, for yours: ```sh fctl host install --workspace acme ``` This writes `~/.config/fctl/host.toml` and installs a launchd agent (`sh.fctl.host`) that starts at login, restarts if it stops, and keeps the Mac from sleeping while it is plugged in, so it can start work at any hour. The host then pulls the [sandbox image](https://fctl.sh/docs/sandbox-images.md) (Debian with git, gh, Python, uv, Rust, Node, TypeScript, Bun and Claude Code) in the background. With Lima, add `--build-image`: it prepares the VM that sessions are cloned from, which takes a few minutes. A host runs any image a project chooses; `--allow-image 'ghcr.io/acme/*'` limits that, and `--allow-build 'acme/*'` lets it build images from those repositories ([Which images a host runs](https://fctl.sh/docs/sandbox-images.md#which-images-a-host-runs)). 3. Check it: ```sh fctl host doctor fctl host status ``` The machine appears under the workspace's **Runners** tab as online, and the workspace's sessions start on it. It is yours: add it to your other workspaces from their Runners tab, or with `fctl workspaces add-runner -w side-project` on the machine, and it takes the sessions of all of them. Elsewhere, that command takes the runner's name or the id `fctl host status` shows. For a Mac that runs around the clock, keep it plugged in and turn on automatic login, so the host starts again after a reboot or a power cut. A runner sees the code, secrets and Claude token of each session it runs. To keep yours on machines you control, turn on **Only on my runners** in the workspace's **Settings** (`fctl workspaces own-runners on -w acme`): your sessions there then run only on runners you registered, and wait while none of those is online. A Claude sign-in through a host runs on one of your runners when one is online. To keep your Claude token away from fctl too, keep it on your runners ([step 4](https://fctl.sh/docs/getting-started.md#4-connect-your-claude-subscription)): your sessions on it then run only on your runners that keep it, in every workspace. The host keeps itself on the version of fctl the app runs: when the app runs a newer one, the host downloads it, checks it, and restarts on it once no session is being started or stopped there, while its sessions carry on. To update it yourself instead, turn that off with `fctl host auto-update off` (or install with `--auto-update off`). Where Homebrew installed fctl, Homebrew updates it: `brew upgrade fctl`, then `fctl host restart`. While a runner of yours that doesn't update itself is online with an older fctl than the app, your Agent Floor says so, and **How to update** shows what to run on it: `fctl self update`, which restarts the host service while its sessions carry on. **Skip this version** hides the notice in that browser until the app runs a newer version. ## 6. Create a project In the app, choose **New project** (or the **+** beside a workspace in the sidebar); a workspace's admins create its projects, and add the people who work in them. Link an existing repository of a GitHub account the workspace works in, create a new one, or start without a repository. A workspace works in an account once the instance's GitHub App is installed there and one of its admins connects it under the workspace's **GitHub** tab, by signing in to GitHub, which connects what that person may give the workspace. Optionally add a setup script (it runs in the clone before the agent starts, e.g. `npm ci`) and instructions for agents. When sessions need tools the default image lacks, give the project an image of its own, or one built from a Dockerfile or devcontainer.json in its repository, under Settings → Sandbox → Image ([Your own image](https://fctl.sh/docs/sandbox-images.md#your-own-image)). The **claude.ai connectors** switch starts off: turn it on only if agents should use the connectors (mail, files, docs) of whoever starts a session, without asking. From the terminal: ```sh fctl projects create "My app" --repo my-org/my-app fctl projects use my-app fctl projects set --setup-script 'npm ci' --instructions-file agents.md fctl projects secrets set STRIPE_KEY # asks for the value fctl projects members add grace@example.com ``` Agents can use more repositories of the same account: add them under the project's **Repositories** tab, each read only (a private dependency, say) or read and write, or let agents read every repository the GitHub App can reach. From the terminal: `fctl projects repos add my-org/my-lib --write`. ## 7. Start a session Describe the task on the Agent Floor, the page the web app opens on, or on the project page, or: ```sh fctl run "Add a dark mode toggle to the settings page" ``` On a computer, the Agent Floor opens with the cursor in its task box. From anywhere in the app, ⌘K and then Enter take you there (Ctrl+K and Enter on Linux and Windows), and ⌘Enter (Ctrl+Enter) starts the session. ⌘K opens the search that jumps to sessions, projects and workspaces, and finds what sessions said (see [Follow along and steer](https://fctl.sh/docs/getting-started.md#8-follow-along-and-steer)), and ⌘P and ⌘⇧P open it too, as in code editors. The two lists by the Start button choose the model the session runs on (Opus, Sonnet, Haiku or Fable, each the latest of its family) and its effort level. They start on what you chose when you last started a session in the project, else on the project's settings (its **Settings** tab sets them for everyone). On a phone, Start is an arrow (↑), and the lists show the model's name and the effort level's symbol, the one Claude Code shows for it: ○ low, ◐ medium, ● high, ◉ extra high, ◈ max. In the terminal: `fctl run --model haiku --effort low "…"`. To show the agent what you mean, attach screenshots and files: with the paperclip, by pasting them into the message box, or by dropping them on the page (`fctl run -a screenshot.png "…"` in the terminal). The agent sees them in its sandbox and gets screenshots in the message itself. A task you write on the Agent Floor stays in your browser, with its project, model, effort level and files, until a session starts with it: leave the page, and it is there when you come back. So does one you write on a project's page: each project keeps its own, apart from the Agent Floor's. `fctl run` follows the session live in your terminal; Ctrl-C detaches and the agent keeps working. The agent works on a branch named `fctl/<number>-<title>`, commits as it goes, pushes, and opens a pull request when it is done (projects can turn that off). In the project's other repositories it changes, it uses the same branch and opens a pull request in each. The session page and the session lists name every pull request the session opens, with its repository (`fctl#120`, `design#14`), as does `fctl show`. The session page's Changes section shows what the branch changed since it left the base, the first ten files of each repository and the rest a click away; once its pull request is merged, Changes says so, and fctl no longer pushes the branch. Once it understands the task it renames the session to what it is working on and describes it in a sentence or two, so the Agent Floor shows the work rather than the first line of your task. The agent's commits are authored by the GitHub App, with Claude as co-author. To be a co-author of the commits in the sessions you start, set a commit email in **Settings → Email addresses**, for the address you are in the workspace as: an address your GitHub account has, such as its noreply address. Anyone who can read the repository sees it. A project's settings decide who its commits credit: **Credit people in commits** and **Credit Claude in commits** are both on until a maintainer turns them off. ## 8. Follow along and steer The Agent Floor lists the sessions at work under **Working now**, and the latest that ended under **Recently**. A project's page lists its sessions the same way, with **Recently** for the ended ones that last moved in the past day and **Older** for the rest, the most recent first. There **Active** shows only the sessions at work, and **Mine** only the ones you started. - **Web app** — the session page streams the transcript as the agent writes it: tool calls, diffs, and the files changed, commits and pull requests so far, seconds after the agent makes them. The tool calls and thoughts between two things the agent says fold into one line that says what they did, like "Ran 6 commands, read 2 files", and opens into them; while the agent works, its newest step shows under that line. Where the agent delegated to a subagent, its line opens into what the subagent was asked, its steps as it takes them, in the background too, and its report. A click on an image or a screenshot shows it at full size, where the arrow keys, or a swipe on a phone, step through the session's others, and on a phone a pinch or a double tap zooms in on it. The details beside the transcript show how full Claude's context is, with where Claude compacts the conversation to make room. On the session page, as on the project's, the sidebar lists the project's sessions that haven't ended under the project (starting, at work, waiting or paused), the one you are on marked, so a click switches to another. On a phone, tap the session's title for its details: its pull requests, the files changed, its branch and who else is watching. Type below to message the agent, with files attached if you like; it reads your message at its next step. **Interrupt** stops the current turn. - **Terminal** — `fctl attach my-app#12` streams the session and lets you type messages; `fctl send my-app#12 "Use the existing Button component"` sends one from a script. - **Claude apps** — a session started with a full Claude login joins Remote Control, in the Claude apps of the person whose subscription it runs on: who started it, or who resumed it. Only they can open it there, so only they see **Open in Claude** on the session page, which opens it in Claude's desktop or mobile app, and **Open on claude.ai** in its menu, which opens it in a browser. From a terminal, `fctl open my-app#12 --claude` opens the desktop app, or claude.ai when it is not installed. To find what a session said, press ⌘F (Ctrl+F) on its page, or choose **Find in this session** in its menu on a phone. The bar under its header finds what you type in all the transcript says, folded or not: messages, the agent's replies, the lines of its work and the commands behind them, its thoughts and its subagents' work. It counts the matches from the newest; Enter (or ↑) goes up to the one before, Shift+Enter (or ↓) back down, and each opens what folds it. Going up past the oldest match loads earlier activity to look in it too, and so does finding nothing in what has loaded. A second ⌘F there opens your browser's own find. To find it in any session you can see, type it in ⌘K: past the sessions whose titles match, it lists those whose messages or agent's replies say it, and opens one at that message with its find bar on it. From a terminal, `fctl search "login redirect"`. Both find what you type as you type it, whatever the case, the accents and the spaces between the words. If the agent's work stops coming through, because the session's runner went away or can't reach the agent in its sandbox, the session page says so and since when. The agent may well be working meanwhile: its work shows up once the runner gets through, and messages you send wait for it. After ten minutes away the session shows as stopped, and a message resumes it on another runner. If the runner comes back with the agent still running before the session runs anywhere else, the session carries on there. When the agent finishes a turn, the session waits for you. After the project's idle timeout (ten minutes unless its settings say otherwise) it stops, so that its machine takes other work. Work the agent left running in the background, like a review by a subagent or a long build, keeps the session up for up to an hour after the turn; the agent picks up its result in a turn of its own. A message picks it up where it left off, on the same machine, with the same conversation and branch: type it on the session page or send it with `fctl send`. A session that failed needs its owner to **Resume** it (or `fctl resume my-app#12 "…"`). ## 9. Work together A workspace's admins invite people from its **People** tab (`fctl workspaces invite grace@example.com`), by an address they sign in with; they join it from their Agent Floor. An address the instance doesn't admit yet needs an instance admin's invitation first (**Admin → People → Invite**, or `fctl admin invite grace@example.com -w side-project`), which can invite it to the admin's workspaces at the same time. One the allowlist admits but nobody signed up with yet can be invited right away: its owner joins when they sign up. In a workspace, admins own every project, and members work in the projects they are members of (a project's **Members**, or `fctl projects members`) and in every project with the workspace's base role, which its admins choose in its settings: | Role | Can | |---|---| | Viewer | read sessions | | Editor | start sessions and message agents | | Maintainer | change settings and secrets, manage members | | Owner | everything, including deleting the project | Share a single session with someone else in the workspace from its **Share** menu, or with `fctl share my-app#12 grace@example.com`. Everyone watching a session sees who else is there. A workspace has one primary admin, whom no other admin can demote or remove; they hand the role to another admin under its **Settings**, as the primary instance owner does under **Admin → People**. --- Source: https://fctl.sh/docs/cli.md # The CLI `fctl` does everything the web app does, for people and for scripts and agents. Every command takes `--json` for machine-readable output and `--server <url>` (or `FCTL_SERVER`) to pick an instance; `fctl <command> --help` has the details. ## Signing in ```sh fctl login app.fctl.sh # opens the browser to approve this device fctl login --no-browser # prints the link and code instead (SSH, servers) fctl whoami fctl logout ``` You approve the device in the browser, signed in there, so two-factor sign-in happens in the browser too. `fctl login` opens only a link on the server it signs in to: a server that sends you to approve the sign-in on another site could be relaying that site's code to get a token there, so it stops and says which site to sign in to instead. For scripts, CI and bots, create an access token, limited to what it is for, and pass it as `FCTL_TOKEN`: ```sh fctl token "PR reviews" --access run -p my-app --github comment --days 90 FCTL_TOKEN=fctl_pat_… fctl run -p my-app --github comment -d "Review pull request #12" ``` ### Access tokens `fctl token` prints a new token once. Without `--access`, `-p`, `-w` and `--github` it does everything you can, as you do when signed in; with any, it is limited, and reaches projects and sessions only: never your account, your workspaces' people and settings, your Claude connections or your other tokens. ```sh fctl token "Dashboard" --access read -w acme # reads every project of acme, those made later too fctl token "Nightly" --access run -p my-app # starts and steers sessions in my-app fctl token "Admin script" --access full -p my-app # everything you can do in my-app fctl token "PR reviews" --access run -p my-app --github comment --days 90 fctl tokens # your tokens and what each does, and where you are signed in fctl tokens revoke "PR reviews" # by name or id (tok_…), at once ``` `--access` says what it does: `read` reads projects, sessions, transcripts, memory and retrospectives and changes nothing; `run` also starts sessions, messages and interrupts their agents, stops and resumes the sessions it started, and keeps projects' memory; `full` does all you can in its projects. `-p` and `-w` (repeat them) keep it to some projects, or to every project of some workspaces. `--github` is the most the agents of its sessions may do on GitHub (below), for a token that runs sessions (it makes one, without `--access`): sessions it starts get that unless they ask for less, and it can't message or resume a session whose agent started able to do more. A token that does everything can't take it, since it could change the settings that steer its projects' agents. Sessions it starts don't notify you; follow the ones you want. `fctl whoami` with a token says what it does. [Automation](https://fctl.sh/docs/automation.md) runs review agents from GitHub Actions with one. Configuration lives in `~/.config/fctl/` (`config.toml`, and `credentials.toml` with mode 0600). `FCTL_CONFIG_DIR` moves it. `FCTL_SERVER` and `FCTL_TOKEN` take the place of the instance and token `fctl login` saved there, and `--server` the place of both. ## Sessions Sessions are named by their id (`ses_…`), their URL, or `project#number`, which fctl looks up among the project's latest 200 sessions: scripts keep the id. ```sh fctl run -p my-app "Add a dark mode toggle" # start one and follow it fctl run -p my-app -d -f task.md # start from a file, don't follow fctl run -p my-app -d --json "…" | jq -r .id # the new session's id, for scripts echo "Fix the flaky test" | fctl run -p my-app fctl run --title "Fix the login loop" "…" # its title (default: the task's first line) fctl run --model opus --effort high "…" # override the project's agent settings fctl run --github comment "Review pull request #12" # its agent reviews and comments, and pushes nothing fctl run --github read "Plan the move to Postgres" # its agent only reads on GitHub fctl run --github-issues "Move the open GitHub issues to Linear" # its agent reaches GitHub's issues, whatever the tracker fctl run --branch fix/login --base develop "…" fctl run -p my-app -a screenshot.png "Make the header match this" # attach files (repeat -a) fctl sessions # recent sessions (only the default project's, once fctl projects use set one) fctl sessions --active -p my-app fctl sessions -w acme --limit 100 # a workspace's (30 unless --limit says) fctl show my-app#12 # status, branch, pull requests and their checks, model and effort, usage fctl edit my-app#12 --title "Fix the flaky login test" fctl edit my-app#12 --description "Retries the redirect and tests it." # "" clears it fctl logs my-app#12 # the transcript fctl logs -f my-app#12 # …and keep following fctl search login redirect # the sessions that say it, and where fctl search -p my-app "flaky test" --limit 50 fctl attach my-app#12 # stream it and type messages (Ctrl-C detaches) fctl attach --read-only my-app#12 fctl send my-app#12 "Use the existing Button component" # a stopped session resumes for it fctl send my-app#12 -a ci.log "This is what CI printed" fctl interrupt my-app#12 # stop the current turn fctl stop my-app#12 # end the session (its sandbox is kept for resuming) fctl resume my-app#12 "Now add tests" # also a failed one, on your Claude subscription fctl follow my-app#12 # get its notifications (see Notifications) fctl unfollow my-app#12 fctl share my-app#12 grace@example.com # they view it and message its agent (--role viewer, editor or owner) fctl share my-app#12 # who it is shared with fctl unshare my-app#12 grace@example.com fctl archive my-app#12 # out of the lists; its pull requests no longer bring its agent back fctl unarchive my-app#12 fctl sessions --archived # the archived ones too fctl open my-app#12 # in the browser fctl open my-app#12 --claude # in the Claude app (Remote Control) ``` `fctl run` uses the project from `fctl projects use` when `-p` is missing. `fctl run` follows the session as `fctl attach` does, until you press Ctrl-C, also once the session has stopped; with `-d` it returns as soon as the session is created, which is what scripts and agents want. `fctl attach --read-only` and `fctl logs -f` return at once for a session that had already stopped or failed when they connected, and otherwise keep following, after it stops too. Without message text, `fctl run` and `fctl send` read it from stdin, to its end, when stdin isn't a terminal: where stdin stays open, they wait. [fctl for agents](https://fctl.sh/docs/agents.md) has the rest of what matters when a program drives fctl, and how to wait for a session's turn to end. `fctl search` looks in the titles and descriptions of the sessions you can see, in every workspace unless `-w` or `-p` says which, and in what people wrote to their agents and the agents replied; not in tool calls or what they returned. It finds the words as you type them, whatever the case, the accents and the spaces between them, and lists the sessions that have them in their title first, then those that said them most recently, each with up to three snippets of where (`--json` for scripts). A session is searchable a few seconds after its messages come; ones from before search existed, once the control plane's hourly job got to them. A session's agent pushes, comments or only reads on GitHub (`--github`): one that pushes commits and pushes its branch, as agents always did; one that comments reviews, comments and labels, with a token that can't push; one that reads writes nothing. It gets as much as its project's settings and the token that starts it allow unless it asks for less, and `fctl show` says what it got. GitHub lets only tokens that can push resolve review threads, so an agent that comments resolves them through fctl, in its sandbox: ```sh fctl resolve PRRT_kwDOABCD1234 # in a session's sandbox: resolve a review thread fctl resolve --unresolve PRRT_kwDOABCD1234 ``` `fctl attach` also says when the session's runner goes away or can't reach the agent in its sandbox, and when it is back. A session's project's people see it already; `fctl share` shares it with someone else in its workspace, by the address they are in it as, and `--role` says what they may do: `viewer` reads it, `editor` (the default) also messages and interrupts its agent, and `owner` also stops, resumes, archives and shares it. Only who started it, or an admin of its workspace, shares ownership. Whoever can message a session's agent can have it hand over what its sandbox holds: the project's secrets, its GitHub tokens and the Claude token it runs on. `fctl archive` puts a session away: lists leave it out (`fctl sessions --archived` keeps it in), and nothing that happens to its pull requests reaches its agent any more, though messages still do. Sharing and archiving are for the session's owners. A session's title starts as the first line of its task. Agents rename it to what they work on, and say what the session is about, with the same command: in a sandbox, `fctl edit --title … --description …` without a session edits the one the sandbox runs, through fctl-agent and without a sign-in. The sandbox's `~/AGENTS.md` asks them to do it once they understand the task, and again when the work turns to something else. `--attach` (`-a`) sends files with the task or message (`run`, `send`, `resume`): up to 10, of at most 20 MB each. The agent finds them in `~/attachments/` in its sandbox, and sees images that Claude accepts as they are (PNG, JPEG, GIF, WebP up to 2000 px a side) in the message itself. ### Screenshots in pull requests GitHub takes uploads only from people, so in a sandbox `gh pr edit --attach` fails with "unsupported authentication type": sessions have the GitHub App's token. In projects that publish screenshots, agents publish them on fctl instead and put the Markdown it prints in the pull request: ```sh fctl publish before.png after.png # in a sandbox: a link and Markdown for each fctl publish --json after.png # the links, sizes, expiry and Markdown fctl unpublish https://app.fctl.sh/api/v1/published/…/after.png # delete one sooner ``` Anyone who has a link can open it, until it expires: after 30 days, or what the project chooses between 24 hours and 60 days. A link holds 256 random bits. Publishing is off until the instance's admins allow it (`fctl admin set --publish-screenshots on`) and a project's maintainers turn it on (`fctl projects set --publish-screenshots on`); turning either off takes the screenshots offline. It takes PNG, JPEG, GIF and WebP images of at most 5 MB, up to 100 per session. The transcript shows each one, and people who manage the session can delete it there or with `fctl unpublish`. ## Workspaces Projects, the runners that run their sessions and the GitHub accounts they work in belong to workspaces. `fctl workspaces` (or `fctl workspace`) lists yours and the invitations waiting for you; the commands below take the workspace with `-w`, else the one from `fctl workspaces use`, else your only one. ```sh fctl workspaces # yours, and the invitations waiting for you fctl workspaces create "Side project" --color pink fctl workspaces create Acme --as ada.lovelace@acme.example # join it as another of your addresses fctl workspaces show -w side-project fctl workspaces use side-project # the default for the commands below fctl workspaces order side-project acme # yours in this order, in lists and the app's sidebar fctl workspaces join side-project # an invitation (or decline it) fctl workspaces leave side-project # your sessions there stop fctl workspaces claude crd_01… -w acme # your sessions there run on this Claude connection fctl workspaces claude ada@acme.example -w acme # …named by its account or label fctl workspaces claude on-runners -w acme # …on the token you keep on your runners fctl workspaces claude default -w acme # …or on your default one fctl workspaces own-runners on -w acme # your sessions there run only on runners you registered fctl workspaces own-runners off -w acme # …or on any of its runners for everyone's sessions too fctl workspaces members # its people, and the invitations nobody answered fctl workspaces invite grace@example.com --admin fctl workspaces uninvite grace@example.com fctl workspaces role grace@example.com member fctl workspaces remove grace@example.com # their sessions there stop fctl workspaces transfer grace@example.com # the primary admin hands the role to an admin fctl workspaces set --name "Side project" --avatar logo.png # or --avatar none fctl workspaces set --slug side # its address in URLs (create takes --slug too); old links stop working fctl workspaces set --base-role editor # every member starts sessions in every project fctl workspaces set --require-two-factor on # closed to members without a second factor fctl workspaces set --skip-invites on # a workspace for one: its setup stops asking for people fctl workspaces runners # the runners its sessions start on, with their ids fctl workspaces add-runner studio-mac # one of yours runs its sessions too (its name or id) fctl workspaces add-runner # on a runner: this machine fctl workspaces add-runner studio-mac --personal # only your sessions there (a member's always) fctl workspaces remove-runner studio-mac # the ones running there stop and resume elsewhere fctl workspaces github # the GitHub accounts it works in fctl workspaces connect # opens a GitHub sign-in; you choose what it works in on its GitHub tab fctl workspaces connect acme # the same, for acme only fctl workspaces connect acme --repo web --repo design # only these repositories of it, without the choice fctl workspaces connect --no-browser # print the link to open where you are signed in (linear connect takes it too) fctl workspaces disconnect acme fctl workspaces linear # the Linear workspaces it works in, and which projects take their tickets fctl workspaces linear connect # opens Linear, where one of its admins installs fctl's app fctl workspaces linear teams # the teams fctl's app reaches there, for projects to choose fctl workspaces linear disconnect acme # its projects take no more tickets from it fctl workspaces audit # what happened in it (admins) fctl workspaces delete side-project # with its projects and sessions; asks you to type its name fctl workspaces delete side-project --confirm "Side project" # …or takes it, without a terminal ``` Admins invite people by an address they sign in with, or one the allowlist admits (they join when they sign up); any other address needs an instance invitation first (`fctl admin invite`). Everyone in a workspace works in its projects as their project role or its base role says; its admins own every project. One person can be in at most five workspaces, and the primary instance owner in any number (`fctl workspaces --json` says `"unlimited": true` to them). While the instance requires two-factor sign-in (`fctl admin set --require-two-factor on`), every workspace does, and `fctl workspaces set --require-two-factor off` fails. A runner its admins add runs everyone's sessions there; one a member adds, and one an admin adds with `--personal`, runs only its owner's. With `own-runners on`, your sessions in the workspace run only on runners you registered, not on its other people's, which see a session's code, secrets and Claude token; turning it on stops the ones running elsewhere, and they resume on yours. ## Projects ```sh fctl projects list # all your workspaces' projects fctl projects list -w side-project fctl projects show my-app fctl projects create "My app" --repo my-org/my-app -w acme # a workspace's admins create its projects fctl projects create "Docs" --repo my-org/docs --slug docs --description "The handbook" # its URL name, and a sentence for people fctl projects create "New thing" --new-repo new-thing --owner my-org --public fctl projects create "Site" --repo my-org/site --connectors # claude.ai connectors in its sessions (off by default) fctl projects set -p my-app --name "Web app" --description "The storefront" # how people find it; its slug stays (--description "" clears it) fctl projects set -p my-app --default-branch develop # sessions start from develop unless they ask for another (fctl run --base) fctl projects set -p my-app --model opus --effort high # its agents' model and effort ("default" for Claude's own) fctl projects set -p my-app --instructions-file agents.md # what its agents are told in every session (--instructions "" for nothing) fctl projects set -p my-app --auto-pull-request off # agents open pull requests when someone asks, not when they finish fctl projects set -p my-app --remote-control off # its sessions stay out of the Claude apps fctl projects set -p my-app --connectors on # agents use the claude.ai connectors of whoever starts a session fctl projects set -p my-app --credit-claude off # no Claude trailer in its commits fctl projects set -p my-app --credit-people off # no trailers for the people who start sessions fctl projects set -p my-app --idle-timeout 30 # idle sessions keep their sandbox 30 minutes (default 10) fctl projects set -p my-app --publish-screenshots on --screenshot-days 7 # links work for a week fctl projects set -p my-app --image ghcr.io/acme/agent:1 # sessions start from this image ("" for the runner's) fctl projects set -p my-app --build .devcontainer/devcontainer.json # or one built from the repository fctl projects set -p my-app --build Dockerfile --build-context app # a Dockerfile, built in app/ fctl projects set -p my-app --setup-script 'npm ci' # or @setup.sh, or - to read it from stdin fctl projects set -p my-app --package-caches on # sessions on a runner share their downloads fctl projects set -p my-app --env NODE_ENV=test --unset-env DEBUG fctl projects set -p my-app --cpus 8 --memory 16g # "default" for the runner's fctl projects set -p my-app --project-memory off --retrospectives off # agents keep neither fctl projects set -p my-app --github comment # its agents review and comment at most, and push nothing fctl projects set -p my-app --pr-descriptions brief # pull requests described in a few sentences (balanced by default, or full) fctl projects set -p my-app --follow-ups comments,checks,conflicts # agents come back for @fctl comments, failed checks and conflicts fctl projects set -p my-app --tickets linear --linear-team ENG # its tickets are a Linear team's fctl projects use my-app fctl projects order my-app site # these first in their workspace, the others after them fctl projects delete my-app # with its sessions; asks you to type the slug fctl projects delete my-app --confirm my-app # …or takes it, without a terminal ``` `--env` sets variables that aren't secret: the project's people see them in its settings and `fctl projects show`. Credentials go in its secrets, which sessions get as environment variables too, and whose values nobody reads back: `fctl projects secrets set` asks for the value without showing what you type, or reads it from stdin, never from an argument, which shell history would keep. Anything an agent runs can read both variables and secrets. ```sh fctl projects secrets -p my-app # their names, and who set each when fctl projects secrets set STRIPE_KEY -p my-app # asks for the value printenv STRIPE_KEY | fctl projects secrets set STRIPE_KEY -p my-app fctl projects secrets set DEPLOY_KEY -p my-app < deploy_key.pem fctl projects secrets remove STRIPE_KEY -p my-app fctl projects members -p my-app # its members and their roles fctl projects members add grace@example.com -p my-app # an editor (--role viewer, editor, maintainer or owner) fctl projects members role grace@example.com maintainer -p my-app fctl projects members remove grace@example.com -p my-app # your own address leaves it ``` Maintainers set its settings and secrets and manage its members; only owners make someone an owner or change an owner's role, and a project keeps at least one owner. Its members are people of its workspace, added by the address they are in it as, and besides them the workspace's admins own the project and every member of the workspace has the workspace's base role in it ([Workspaces](https://fctl.sh/docs/cli.md#workspaces)). Agents start in the project's repository and can use more of the same GitHub account. They clone them when they need them, into `~/repos/<owner>/<name>`; in each one they change, they work on the session's branch, which fctl pushes after every turn until its pull request is merged, and open a pull request. ```sh fctl projects repos list -p my-app fctl projects repos add my-org/my-lib --write -p my-app # agents may push and open pull requests fctl projects repos add my-org/design -p my-app # read only, like a private dependency fctl projects repos add 'my-org/*' -p my-app # read every repository the GitHub App reaches fctl projects repos remove my-org/design -p my-app ``` Changes apply to sessions that start afterwards. Follow-ups bring agents back to the pull requests they opened, in a message from fctl that resumes the session on the Claude subscription it ran on (`--follow-ups`, a list of `comments`, `checks`, `conflicts` and `closed`, or `all` or `none`): a comment that mentions `@fctl` or `@agent`, or starts with `fctl:` or `agent:`, from someone who can write to the repository; a failed check of the newest commit, at most three times in a row while nobody messages the agent; conflicts with the base branch, which the agent resolves by merging the base branch in, as often; a merge or a close. New projects start with all four on; `fctl projects show` says which ([Architecture](https://fctl.sh/docs/architecture.md#follow-ups)). A project's image and its build run only on runners whose owners allow them ([Sandbox images](https://fctl.sh/docs/sandbox-images.md#which-images-a-host-runs)); a session that no runner allows stays queued and says why. `fctl projects show` says where the project's sessions start from, their size, setup script and variables. With `--package-caches on`, the project's sessions on each runner share what npm, pnpm, Yarn, Bun, Cargo, uv, pip, Go and Playwright download, so a setup script like `npm ci` or `cargo fetch` gets only what is new. The runner keeps them for the project up to its owner's limit, 10 GiB unless `host.toml` says otherwise, empties them past it, and removes them two weeks after a session last used them ([Hosts](https://fctl.sh/docs/cli.md#hosts)). Sessions get them on runners with the Docker or Apple container driver (and the process driver), not Lima, and review agents never do. A session in a sandbox made before the project turned them on gets them in its next sandbox. ### Ticket trackers A project's ticket tracker is where its agents read and write tickets, and what they are told to use: GitHub Issues, a Linear team, or none. Projects that never chose one have GitHub Issues of the repositories their agents write to, as agents always worked: ```sh fctl projects tickets -p my-app # its tracker, how agents reach it, and what its tickets started lately fctl projects set -p my-app --tickets github # the issues of every repository its agents write to fctl projects set -p my-app --tickets github --tickets-repo my-org/my-app # one of them only fctl projects set -p my-app --tickets none # no tracker at all, GitHub's issues included fctl workspaces linear teams # the Linear teams fctl's app reaches fctl projects set -p my-app --tickets linear --linear-team ENG fctl projects set -p my-app --linear-project "Mobile app" # only that Linear project's issues ("all": the team's) fctl projects set -p my-app --linear-start off # tickets stay in their state as their sessions start fctl projects set -p my-app --explore-label plan # the label that asks for a plan first ("" for none) ``` With the issues of every repository they write to, agents use `gh`, as before. With one repository's, or a Linear team's, they use `fctl ticket`, which fctl answers for them: it holds the tracker's access and refuses tickets outside the project's choice as tickets that don't exist. With none, their GitHub tokens carry no issues permission and they are told to leave every tracker alone; a public repository's issues stay readable to anyone, though. A token covers all its repositories with one set of permissions, so with one repository among several the session's tokens get no issues permission either. A change reaches running sessions' GitHub tokens with their next one, within the hour, and what agents are told from their next session on. A maintainer of the project can start a session that reaches GitHub's issues whatever the tracker says, to move them into Linear, say: `fctl run --github-issues`, or **GitHub issues too** in the start form's GitHub menu. Its tokens carry the issues permission on the repositories it writes to, as with GitHub Issues of all of them, for as long as it runs, and its agent is told to use those issues only as its task asks. It reaches the project's tracker as other sessions do. A Linear team takes a Linear workspace the project's workspace works in ([Workspaces](https://fctl.sh/docs/cli.md#workspaces): `fctl workspaces linear connect`). There, people assign an issue to fctl, which makes fctl its delegate, or mention fctl in a comment, and fctl starts a session of the project that takes the issue's team, on the subscription of whoever asked, once they have linked their Linear account ([Your account](https://fctl.sh/docs/cli.md#your-account)) and only while they may start sessions in the project. The ticket moves to the team's first started state (In Progress), the session's branch carries its key (`fctl/12-eng-123-fix-the-login-loop`) for Linear's GitHub integration to link the pull request, and fctl posts the agent's steps, its pull requests and each turn's last message to the ticket's agent session in Linear. Replies there reach the agent as their writers' messages, and stopping the agent session there interrupts it. An issue with the explore label (`explore` unless a project names another) gets a plan, and no changes until someone asks for them ([Architecture](https://fctl.sh/docs/architecture.md#linear)). In a session's sandbox, `fctl ticket` is the agent's, through fctl-agent and without a sign-in, as for `fctl edit`: ```sh fctl ticket # the tracker, and the ticket this session works on fctl ticket list --search login --label Bug # open ones, most recently changed first (--closed, --all, --limit up to 50) fctl ticket view ENG-123 # its description, parent, sub-tickets and comments fctl ticket create --title "Add a test for retries" --parent ENG-123 --description-file notes.md fctl ticket create --title "…" --label Tests --state Todo --priority High # state and priority: Linear fctl ticket create --title "…" --in my-org/api # which repository, for the GitHub issues of several fctl ticket comment ENG-123 "The fix needs a migration first: should it run in the deploy?" fctl ticket comment ENG-123 --reply-to 2f6c… --file answer.md # answer a comment, where trackers thread them fctl ticket update ENG-123 --state "In Review" --add-label Bug fctl ticket update ENG-123 --remove-label Triage --parent none --priority Low fctl ticket states # the states tickets can be in, in order fctl ticket labels ``` In an hour, a session's agent comments at most 30 times, files at most 20 tickets, changes at most 60 and reads its tracker at most 300 times. Review agents reach no tracker. ### Memory and retrospectives A project keeps a memory: notes its agents, and its people, write for the agents of its later sessions, about what the repository doesn't say. Each session's agent starts with the current notes, adds what a future agent should know and marks the notes that turned out wrong stale; everyone in the project reads them, and editors change and delete them: ```sh fctl memory -p my-app # the current notes, newest first, with where each came from fctl memory -p my-app --stale # the stale ones too fctl memory show mem_01JC3V… fctl memory add -p my-app "Deploys happen on Fridays: merge migrations by Wednesday." fctl memory add -p my-app "Deploys happen on Thursdays." --replaces mem_01JC3V… # the old one goes stale fctl memory add -p my-app "…what both say…" --replaces mem_01JC3V…,mem_01JC2Q… # in place of both fctl memory edit mem_01JC3V… "Deploys happen on Thursdays." fctl memory stale mem_01JC3V… --reason "We deploy every day now" # agents no longer start with it fctl memory duplicate mem_01JC3V… --of mem_01JC2Q… # stale, linked to the note it repeats fctl memory restore mem_01JC3V… # current again fctl memory rm mem_01JC3V… # for good ``` As agents finish a piece of work, they record a retrospective of it: how it ended, what they worked on, the approach they took, what got in the way and what a future agent should know. One for each piece of work, not for each turn: when a later turn carries on with it, the agent updates the one it recorded. Session pages show them, and the project's Retrospectives tab lists them all: ```sh fctl retro list -p my-app # newest first, with how many ended each way fctl retro list -p my-app --outcome blocked --limit 50 # 20 unless --limit says fctl retro list my-app#12 # one session's fctl retro rm rtr_01JC3W… # people who manage the session ``` In a session's sandbox, the same commands without `-p` are the agent's, through fctl-agent and without a sign-in, as for `fctl edit`. `~/AGENTS.md` lists the project's notes and says how to keep them, and when to record a retrospective and when to update one: ```sh fctl memory # the notes as they are now, other sessions' new ones too fctl memory add "The e2e tests need Chromium: run npx playwright install chromium first." fctl memory add "Run cargo xtask check before pushing." --replaces mem_01JC3V… # the old one goes stale fctl memory stale mem_01JC3V… --reason "The script moved to xtask" fctl memory duplicate mem_01JC3V… --of mem_01JC2Q… # it repeats a current note fctl retro --outcome completed --worked-on "Retried failed uploads (#41)." \ --approach "Used the existing backoff helper." --challenges "The tests need MinIO." \ --insights "Start MinIO with docker compose up minio before the tests." fctl retro list # this session's, with their ids fctl retro update rtr_01JC3W… --outcome completed \ --challenges "The tests need MinIO, which CI lacked until #42." # the parts left out stay ``` Agents can't change or delete notes, only mark them stale with a reason, or as duplicates of current notes. They update only their own session's retrospectives, and delete none. A note replaces at most 20 others. Both are on in new projects and projects from before them; `fctl projects set --project-memory off` and `--retrospectives off` turn them off, and the notes and retrospectives stay for people to read. A project keeps at most 500 notes of 2,000 characters, stale ones included, and a session at most 50 retrospectives. ### Review agents Where a project's review agents are on, each new commit of a pull request in the repositories its agents may write to gets a review: an agent reads it, on the Claude subscription of the owner who turned them on, and fctl posts what it found on the pull request, with a check on the commit. A pull request from someone who can't write to the repository gets one only when someone asks for it (`fctl review start`), since what it says reaches an agent with the owner's Claude token. So does an app's, unless it is one of the GitHub Apps the project's owners chose, Dependabot until they choose: their pull requests from the repository's branches get one by themselves, from an agent that installs and runs nothing of them. When an fctl agent opened the pull request, its session gets the findings in a message from fctl, decides what to do about them and answers in their threads. The project's owners turn them on (on their own subscription), take them over and say how they review; the person they run for may turn them off: ```sh fctl review settings -p my-app # on or off, on whose subscription, how fctl review settings -p my-app --on --model opus --effort high fctl review settings -p my-app --instructions-file review.md # what they're told besides fctl's own fctl review settings -p my-app --bots dependabot,renovate # the apps whose pull requests start reviews (or none) fctl review bots -p my-app # the apps that opened the newest pull requests, and which start reviews fctl review bots -p my-app my-app-bot # an app as GitHub shows it: its name and who makes it fctl review settings -p my-app --mine # run them on your subscription from now on fctl review settings -p my-app --model default # the project's model again fctl review settings -p my-app --off # reviews under way finish fctl review -p my-app # the reviews, newest first, with what came of each fctl review start -p my-app 12 # review a pull request's head now (or acme/api#7, or its link) fctl review list -p my-app --limit 50 # more of them (20 unless --limit says) ``` Review sessions take no messages: `fctl show my-app#12` says which pull request one reviews and what came of it. In a review agent's sandbox, `fctl review` is the agent's, through fctl-agent: ```sh fctl review threads # fctl's earlier findings on the pull request, with the answers fctl review submit review.json # post the review, once: --help shows the file ``` ### Schedules A schedule starts a session for a task on its own: once at a set time, or again and again on a cron expression in a time zone. Each run is a session of its own, on the Claude subscription of the person the schedule runs for, who follows it as they follow the sessions they start. A run is skipped while the schedule's last session still works on its task, and while that person's plan is at its limit; one that runs once waits for the limit to reset instead. People who start sessions in the project set up schedules, on their own subscription, and run any of them now, on theirs; a schedule's owner and the project's maintainers and owners change, pause and delete it, and a change by anyone else runs it on theirs from then on: ```sh fctl schedules -p my-app # its schedules, when each runs next and what its last run did fctl schedules add "Weekly dependencies" -p my-app --cron "0 9 * * 1" --tz Europe/Stockholm \ -m "Update the dependencies that have new patch or minor releases, and open a pull request." fctl schedules add -p my-app --cron "0 8 * * 1-5" --task-file triage.md --github comment fctl schedules add -p my-app --at 2h -m "Check whether the deploy went out, and say so." fctl schedules add -p my-app --at 2026-10-30T16:00+01:00 -m "Draft the release notes." fctl schedules add "Triage an issue" -p my-app --when-asked -m "Triage the issue that comes with this." fctl schedules add "Release notes" -p my-app --at 1d --paused -m "…" # set up paused: it runs once resumed fctl schedules show "Weekly dependencies" -p my-app # by name or id: its task, settings and latest runs fctl schedules edit "Weekly dependencies" -p my-app --cron "0 7 * * 1" --effort high fctl schedules edit sch_01JC… --mine # run it on your subscription from now on fctl schedules edit sch_01JC… --name "Monday dependencies" fctl schedules pause sch_01JC… # it starts nothing until it's resumed fctl schedules resume sch_01JC… fctl schedules run sch_01JC… # its task now, in a session on your subscription fctl schedules run sch_01JC… --context "Issue #41 was just opened: the login page hangs" fctl schedules runs sch_01JC… --limit 50 # the sessions it started, and why it started none fctl schedules rm sch_01JC… # the sessions it started stay ``` `--cron` takes five fields (the minute, the hour, the day of the month, the month and the day of the week, like `0 9 * * 1-5`) or `@hourly`, `@daily`, `@weekly`, `@monthly` and `@yearly`, and runs at most every 15 minutes; `--tz` defaults to this machine's time zone. `--at` takes how far ahead (`2h`, `30m`, `1d`) or a time with its UTC offset. `--when-asked` sets up one without a time, which runs only when someone runs it, or another tool does, each run on the subscription of whoever runs it; a paused schedule starts nothing, not even then. `--model`, `--effort`, `--github` and `--base` say what its sessions start with, `default` for the project's. What `--context` says comes after the task; from an access token, its agent takes it as information, not instructions ([Automation](https://fctl.sh/docs/automation.md#start-agents-from-other-tools)). A project keeps at most 50 schedules. ## Notifications A session you follow notifies you when it waits for your reply, pauses on a usage limit or fails, in every browser where you turned notifications on (Settings → Notifications in the web app; on iPhone and iPad, fctl added to the Home Screen). You follow the sessions you start and resume. ```sh fctl notifications status # what notifies you, and the browsers that get it fctl notifications set --waiting off # on or off: --waiting, --paused, --failed fctl notifications set --follow-started off # follow only the sessions you choose fctl notifications test # a test notification to your browsers fctl notifications remove psh_01… # stop notifying a browser ``` Several that arrive close together come as one, and at most one a minute. People who are looking at a session (in the web app with its tab visible, or with `fctl attach`) aren't notified about it. ## Claude ```sh fctl claude connect --login # full sign-in through a host (Remote Control) fctl claude connect # paste a token from `claude setup-token` fctl claude connect --token sk-ant-oat01-… fctl claude connect --on-runners # run on the token your runners keep, which you never give to fctl fctl claude status # your connections, their ids, how much of each plan is used, and which runners keep your token ``` To keep your token on your runners instead of in fctl, run these on each runner, then `fctl claude connect --on-runners` anywhere. Your sessions on that connection run only on your runners that keep the token, in every workspace, and wait while none of them is online: ```sh claude setup-token # makes a token that lasts a year (here, or on any machine with Claude Code) fctl host claude set-token # keeps it in ~/.config/fctl/claude-token, mode 0600 (asked for, or read from stdin) fctl host claude status # whether this runner keeps one, and whether fctl knows fctl host claude remove-token # sessions running here keep it until they stop ``` `claude_token_file` in `host.toml` keeps it somewhere else, such as a file your secrets manager writes. ## Hosts ```sh fctl host install # register this machine and start the service fctl host install --driver lima --capacity 2 --build-image fctl host install --cpus 4 --memory 8g --max-cpus 8 --max-memory 12g # sandbox sizes fctl host install --name studio-mac --image ghcr.io/acme/agent:1 # its name (default: the hostname), and the image for projects that choose none fctl host install --allow-image 'ghcr.io/acme/*' --allow-build 'acme/*' # what projects may run and build fctl host install --workspace acme # for a workspace you are in (default: the one you administer, or your only one) fctl host install --workspace acme --personal # only your sessions there (a member's always) fctl host install --token fctl_rt_… # with a runner token from a workspace's Runners tab (or FCTL_RUNNER_TOKEN) fctl host install --no-service # write the config only (run it yourself) fctl host install --auto-update off # update fctl yourself (new hosts update themselves) fctl host run # run in the foreground (what the service runs) fctl host status # with the runner's id and sessions, and whether it drains fctl host doctor # check the driver, image, agent binary, connection fctl host logs -n 200 fctl host image pull # pull the sandbox image again now (the host does every 6 hours) fctl host image build # build it here instead (with Lima: the base VM) fctl host image build --source ~/src/sandbox # from a checkout of hyperstructure/sandbox fctl host image build --tag acme/sandbox:dev # under another name (default: the host's image) fctl host claude set-token # keep your Claude token here, not in fctl (see Claude above) fctl host auto-update # whether it updates fctl by itself to the control plane's version fctl host auto-update off # or on; restarts the host service (sessions keep running) fctl host restart # sessions keep running fctl host drain # take no new sessions; the ones here carry on fctl host drain --wait # …and wait until none runs here fctl host undrain # take new sessions again fctl host uninstall # sandboxes are kept; asks first when sessions run fctl host uninstall --wait # drain, and uninstall once none runs fctl host uninstall --force # stop the sessions here now, then uninstall ``` Drain a host before maintenance: no new session starts on it, and the sessions it has carry on there. Those that wait for a message stop once they have waited a minute, and a message resumes each of them on this host, where its sandbox has Claude's conversation. So the host is empty whenever no turn runs, and `--wait` returns then. Admins can drain a runner too, from the runners list or with `fctl admin drain`; whoever drained it, either can stop the drain. `fctl host uninstall` stops the service, so it never leaves sessions to agents nobody sees: when sessions run on the host, it lists them and asks before it stops them, or, without a terminal, needs `--wait` or `--force`. Stopped this way, their agents stop and push their work, and a message resumes each on another runner, in a new sandbox without its conversation. The runner keeps draining until `fctl host undrain`. `fctl host install` checks the driver before it registers anything: if the driver can't run sessions (Apple's `container` or Lima isn't installed, a service isn't running), it says what to fix and stops. `--driver auto` (the default) picks the first ready one of apple, docker and lima, never the process driver, and the driver it checked goes into the host's settings in `~/.config/fctl/host.toml`: ```toml server = "https://app.fctl.sh" name = "studio-mac" driver = "apple" # apple | lima | docker | process | auto (picks at every start) capacity = 4 # concurrent sessions # image = "ghcr.io/acme/agent:1" # unset: the published image of the host's version cpus = 4 # per sandbox, when the project sets none memory_mb = 8192 # per sandbox, when the project sets none max_cpus = 8 # the most one sandbox may have (unset: all there is) max_memory_mb = 12288 keep_stopped_hours = 72 # then remove a stopped session's sandbox (0 keeps them) min_free_disk_gb = 20 # disk to keep free for sandboxes (0 keeps none) max_cache_gb = 10 # package caches per project that shares them (0 keeps none) keep_caches_days = 14 # then remove the caches of a project with no session here (0 keeps them) # allowed_images = ["ghcr.io/acme/*"] # images projects may choose (unset: any) # allowed_builds = ["acme/*"] # repositories it builds images from (unset: none) build_timeout_minutes = 60 max_build_mb = 20480 # the largest image a build may make auto_update = true # update fctl to the version the control plane runs (false: `fctl self update`) ``` Sessions start from their project's image or, without one, the host's: unless `image` sets another, the published image of the host's own version, `ghcr.io/hyperstructure/sandbox:<version>`, which the host pulls when it starts. A project's own image runs only if `allowed_images` allows it, or is unset, and an image built from its repository only if `allowed_builds` lists the repository. `--allow-image` and `--allow-build` replace the lists (`--allow-image '*'` for any image, `--allow-build ""` for no builds), and `fctl host status` shows them. [Sandbox images](https://fctl.sh/docs/sandbox-images.md) has the details. A project's Settings → Sandbox can ask for more CPUs or memory than the host's default. Sessions only start on a host whose maximum covers that; when none does, they stay queued and say so ("No runner can give this session 32 GiB of memory (the most is 15.6 GiB)"). Without `max_cpus` and `max_memory_mb`, the maximum is what the machine has, or with Docker the Docker daemon's: on macOS that is the VM of OrbStack, Docker Desktop or Colima, which is usually smaller than the Mac. A default above the maximum is lowered to it. `fctl host status` shows the sizes the host gives. The host measures them when it starts and every five minutes, and the control plane learns them when the host connects, so run `fctl host restart` after changing `host.toml` or resizing the Docker VM. Agents are told their sandbox's size in their instructions and in `FCTL_SANDBOX_CPUS` and `FCTL_SANDBOX_MEMORY_MB`: with Docker, `nproc` and `free` show the whole VM rather than the sandbox's share, and with Apple's `container`, `nproc` counts one CPU more than the sandbox may use. A stopped session's sandbox takes disk on the host until it has been stopped for `keep_stopped_hours`, or until the disk runs low. With Apple's `container`, a sandbox's disk image keeps the space of the files its session deleted (a build of fctl leaves about 5.5 GB) until the host stops the sandbox: then the host gives that space back to the Mac with `container clean`. That needs Apple's `container` 1.4 or later. With an older one, a stopped sandbox keeps the most its session ever stored, and `fctl host logs` says so each time a sandbox stops. `df` in the sandbox shows its 512 GiB disk image rather than what the Mac has free. The host keeps `min_free_disk_gb` free (20 GiB unless `host.toml` says otherwise) on the disk where its sandboxes are: Apple's `container` app root, Lima's `~/.lima`, Docker's root directory or, with Docker on macOS, the disk inside its VM, and the data directory with the process driver. It measures every 30 seconds (inside Docker's VM every five minutes, since that starts a container). With less free than that: - It removes stopped sandboxes before `keep_stopped_hours`, those stopped longest first, one at a time, until there is enough free again. A message to one of those sessions resumes it in a new sandbox on its branch, with what the agent pushed, and Claude starts a new conversation. - It takes no new sessions meanwhile, only resumes of the sessions whose sandboxes it still has. A session that has nowhere else to go stays queued and says "Waiting for a runner: the ones online are low on disk". - `fctl host status`, `fctl host doctor`, `fctl admin runners` and the runners list say how much is free. Sessions that run carry on, and nothing stops them for disk. When no stopped sandbox is left to remove, the host takes sessions again once running ones stop and the host removes their sandboxes, or once something else frees space. Set `min_free_disk_gb = 0` to remove sandboxes by age only, or lower `keep_stopped_hours` to remove them sooner whatever the disk has, and run `fctl host restart` after changing either. For projects that share package caches (`fctl projects set --package-caches on`), the host keeps what their sessions' package managers download: with Docker in a volume per project, `fctl-cache-<project id>`, and with Apple's `container` in a directory of its data directory, `caches/<project id>`, that its sandboxes mount at `/var/cache/fctl`. The sweep that removes stopped sandboxes, when the host starts and every hour, looks after them too: - It measures the caches that a session used since they were last measured, and empties those that take more than `max_cache_gb` (10 GiB unless `host.toml` says otherwise). With Docker, measuring and emptying a volume run in a short-lived container of the host's image. - It removes the caches of a project that no session has started with for `keep_caches_days` (14 unless `host.toml` says otherwise). Those that a stopped sandbox here mounts stay, emptied, until the sandbox goes. - Below `min_free_disk_gb`, it empties caches, those used least recently first, before it removes stopped sandboxes: an empty cache costs the next session downloads, a removed sandbox Claude's conversation. It leaves alone the caches of a project with a session running here, and a session that starts while the host measures or empties its project's caches waits for that. `max_cache_gb = 0` keeps no caches: the next sweep removes them, and sessions download what they need. `fctl host status` shows what the caches took when last measured. The host updates fctl by itself to the version the control plane runs, whenever that is newer than its own, and with it the agent it puts in sandboxes and its default image: on app.fctl.sh, every release. It downloads the release from `download_base` (`https://fctl.sh/releases` unless `host.toml` says otherwise), checks it as `fctl self update` does, and restarts on it once no session is being started or stopped there; sessions keep running. The control plane may run a release before its archives are published: the host tries again every minute meanwhile, for an hour, then every half hour. `fctl host logs` says what it does, and why it doesn't when it doesn't. `auto_update = false` turns this off (`fctl host auto-update off` sets it and restarts the host service; `--auto-update off` when you install). Where Homebrew installed fctl, `brew upgrade fctl` updates it, and a host that can't write to fctl's directory doesn't update itself either: `fctl host status` and `fctl host doctor` say so ([Host updates](https://fctl.sh/docs/architecture.md#host-updates)). ## Admin Instance admins manage people and invitations, the GitHub App's installations, every runner and the instance's settings, and see what each workspace is and who runs it, but not inside the ones they aren't members of. ```sh fctl admin users # with their other addresses and how many workspaces fctl admin invite ada@private.example # an invitation link (and an email, when set up) fctl admin invite grace@example.com -w side-project -w acme:admin # and to your workspaces fctl admin role grace@example.com admin # an instance admin, or member again (the primary instance owner) fctl admin transfer grace@example.com # the primary instance owner hands the role to an admin fctl admin disable grace@example.com # can't sign in; their tokens stop working (or enable) fctl admin delete-user grace@example.com # asks you to type their address (--confirm ADDRESS without a terminal) fctl admin workspaces # and which need a primary admin fctl admin set-primary side-project grace@example.com # when its primary admin's account is disabled fctl admin delete-workspace side-project # asks you to type its name (--confirm NAME without a terminal) fctl admin github # the app's installations, and the workspaces that use each fctl admin github remove acme side-project # take acme from the workspace (its admins reconnect it) fctl admin early-access # who asked for early access, where the instance takes requests fctl admin early-access remove spam@example.com # asking again adds them back fctl admin allowlist fctl admin allow ada@example.com # also creates an invite link fctl admin allow '*@example.com' # a whole domain fctl admin allow grace@example.com --admin fctl admin disallow '*@example.com' # its addresses lose access (and accounts with no other) fctl admin runners # with their ids and images; draining ones say who drained them fctl admin drain studio-mac # no new sessions start on it (name or id) fctl admin undrain studio-mac fctl admin audit # what happened on the instance, newest first fctl admin settings # what the instance allows and requires fctl admin set --publish-screenshots on # projects may publish screenshots fctl admin set --require-two-factor on # everyone who signs in with a password needs it fctl admin set --two-factor-google on # Google sign-ins ask for codes too fctl admin set --verify-sensitive-actions on # sensitive actions take a passkey or a code (the primary instance owner) fctl admin reset-two-factor grace@example.com # they lost their phone and recovery codes ``` `fctl admin users` says who has two-factor sign-in. With it required, the command that requires it lists who hasn't set it up yet: until they do, they can only set it up, in the browser or with `fctl account two-factor enable`, and their other requests fail. Where the instance takes requests for early access (as app.fctl.sh does), `fctl admin early-access` lists the people who asked on the sign-in page and have no account yet, those waiting longest first, then those invited and whether their link still works. `fctl admin invite` with their address answers one: it prints their link, and the request shows as invited. `fctl admin audit` and `fctl workspaces audit` show 100 entries at a time; `--before` with the time after "Older:" (`next_before` in `--json`) shows the ones before them. ## Your account ```sh fctl account emails # your addresses, their commit emails and workspaces fctl account set --commit-email 1234+ada@users.noreply.github.com # your primary address's fctl account set --for ada.lovelace@acme.example --commit-email ada.lovelace@acme.example fctl account set --commit-email "" # commits stop crediting you fctl account primary ada.lovelace@acme.example # where fctl writes to you fctl account disconnect ada@private.example # it stops signing in; you leave its workspaces fctl account export # your data as JSON: fctl-export-<date>.json fctl account export -o - | jq .sessions # or print it fctl account delete # shows what goes, asks for your email address (--confirm EMAIL without a terminal) fctl account linear # who fctl knows you as in Linear workspaces fctl account linear connect # opens Linear's sign-in; what you delegate there runs as you (--no-browser prints the link) fctl account linear disconnect acme ``` Linking a Linear account reads who you are there and revokes the sign-in at once: fctl keeps your Linear id, name, address and avatar, not a token. Each Linear account links to one fctl account, and an fctl account to one Linear account in each Linear workspace. Two-factor sign-in takes a code from an authenticator app after your password, and after Google if you choose: ```sh fctl account two-factor # what you have, and what the instance requires fctl account two-factor enable # prints a QR code, asks for the app's code, shows recovery codes fctl account two-factor enable --code 123456 # replace your app: a code from it, or a recovery code fctl account two-factor recovery-codes # ten new ones; the old ones stop working fctl account two-factor google on # Google sign-ins ask you for a code too fctl account two-factor disable # asks for a code (not while the instance requires it) ``` `2fa` is short for `two-factor`. Without a terminal, `enable --json` prints the setup (the key, the `otpauth://` link and the QR code's rows), and `fctl account two-factor confirm --setup … --code …` finishes it. Where the primary instance owner turns it on, sensitive actions (inviting and promoting admins, turning two-factor sign-in off, adding or registering a runner for a workspace (`fctl host install` too), new access tokens, exporting your data, and deleting accounts, projects and workspaces) ask you to confirm it's you first. In a terminal fctl asks by itself, and then does what you asked: for a code from your authenticator app, or, with a passkey, to approve a code in the browser, as `fctl login` does. The CLI is then confirmed for 15 minutes. Without a terminal, confirm first: ```sh fctl account verify # asks for a code, or opens the browser for your passkey fctl account verify --code 123456 # a code from your authenticator app, or a recovery code ``` Passkeys sign you in with your fingerprint, face or screen lock. Your browser makes them, in Settings → Passkeys; the CLI lists and manages them: ```sh fctl account passkeys list fctl account passkeys rename "Chrome on macOS" "Studio laptop" # by name or id fctl account passkeys remove pky_01… ``` Each address is invited to the instance on its own; open an invitation while signed in to connect its address to your account (the web app asks). Every address signs in, and each has its own commit email: the commits in a workspace's sessions credit the one of the address you joined it as. With a commit email, the commits in the sessions you start end with a `Co-authored-by` trailer that has your name and that address, for everyone who can read the repository. Use an address your GitHub account has, such as its noreply address (GitHub → Settings → Emails), so GitHub links the commits to you. A project's maintainers can turn this off for its sessions (`fctl projects set --credit-people off`). Sessions take a change when they next start or resume; `fctl whoami` shows the address. The export holds your account, sign-in methods and two-factor sign-in (without the authenticator app's secret or the recovery codes), passkeys, devices and tokens, Claude connections (without their secrets), notification settings and browsers, addresses, workspaces, and from the workspaces open to you the projects you are a member of, the sessions you started with their transcripts and the sessions you follow, and your activity log. Deleting your account deletes all of that and the workspaces only you are in; sessions you started in shared workspaces stay there, credited to a deleted user, and the workspaces you are the primary admin of pass to their longest-standing admin. Settings in the web app has both, the deletion in its danger zone. ## Updating ```sh fctl self update # to the latest release fctl self update --check # is there one? fctl self update --version 0.1.2 ``` It downloads the release for this machine, checks its SHA-256, replaces `fctl` (and the Linux agent next to it), restarts a host service on the new version (sessions keep running) and refreshes installed completions. `FCTL_DOWNLOAD` points it at another release server. Installed with Homebrew (`brew install hyperstructure/tap/fctl`)? Update with `brew upgrade fctl` instead; Homebrew owns those files. A runner's host updates itself to the version the control plane runs, unless its `host.toml` turns that off ([Hosts](https://fctl.sh/docs/cli.md#hosts)). The web app's Agent Floor says when a runner of yours is online with an older fctl than the control plane and doesn't update itself, and shows this command. ## Shell completions The installer sets up tab completion for your shell. To do it yourself: ```sh fctl completions --install # zsh, bash or fish, from $SHELL fctl completions zsh > ~/.zfunc/_fctl # or print them: bash, zsh, fish, elvish, powershell ``` `--install` writes the script under `~/.local/share/fctl/completions` (fish: `~/.config/fish/completions`) and adds a marked block once to the end of `~/.zshrc` or `~/.bashrc`. Bash on macOS starts as a login shell, which reads only the first of `~/.bash_profile`, `~/.bash_login` and `~/.profile` that exists, so the block goes in that file (a new `~/.profile` if there is none). Delete the script and the block to undo it. ## Exit codes and output Commands exit with 0 when they succeed, 1 when they fail (the API refused, the network failed, there was nothing to do) and 2 when the command line is wrong (an unknown flag, a missing argument). Errors go to stderr as text, `error: …`, with `--json` too. With `--json`, results go to stdout as one JSON document, in the shapes of [the HTTP API](https://fctl.sh/docs/api.md); `fctl logs --json` prints one event per line instead, with `-f` or without. What waits for you, which matters in scripts and agents' tool calls ([fctl for agents](https://fctl.sh/docs/agents.md)): - `fctl run` without `-d`, and `fctl attach`, follow the session until you press Ctrl-C, also once it has stopped. `fctl attach --read-only` and `fctl logs -f` return at once only for a session that had already stopped or failed when they connected. - `fctl run`, `fctl send` and `fctl schedules add` without their text read it from stdin, to its end, when stdin isn't a terminal. - Deleting a project, a workspace or an account asks you to type its slug, name or address; without a terminal, `--confirm` takes it. `fctl host uninstall` asks before it stops running sessions; without a terminal, it needs `--wait` or `--force`. - Where the instance asks for it, sensitive actions ask you to confirm it's you; without a terminal, they fail until `fctl account verify` has. Environment variables: `FCTL_SERVER` and `FCTL_TOKEN` (in place of the instance and token `fctl login` saved), `FCTL_CONFIG_DIR` (in place of `~/.config/fctl`), `FCTL_RUNNER_TOKEN` (`fctl host install --token`), `FCTL_DOWNLOAD` (the release server `fctl self update` downloads from), `FCTL_LOG` (what it logs to stderr, such as `debug`) and `NO_COLOR`. --- Source: https://fctl.sh/docs/agents.md # fctl for agents This page is for agents, scripts and other programs that drive fctl: a coding agent that hands work to fctl, a CI job, a bot, an issue tracker's automation. It says how to sign in without a person at the keyboard, which commands return and which keep running, how to wait for a session and read what its agent did, what to avoid, and how to set up the common flows. The last part is about the other side: what an agent working in an fctl session can do in its sandbox. [llms.txt](https://fctl.sh/llms.txt) sums all of this up in one file, with fctl's vocabulary. Everything here works the same against app.fctl.sh and an instance of your own: `FCTL_SERVER` says which. [The CLI](https://fctl.sh/docs/cli.md) lists every command, and [the HTTP API](https://fctl.sh/docs/api.md) has the requests behind them. ## Signing in A program uses an access token that a person made for it, limited to what it is for, in `FCTL_TOKEN`: ```sh fctl token "Nightly triage" --access run -p my-app --github comment --days 30 # a person runs this export FCTL_SERVER=https://app.fctl.sh FCTL_TOKEN=fctl_pat_… fctl whoami # whose it is, and what it may do ``` - `--access run` starts sessions, messages and interrupts their agents, stops and resumes the sessions it started, and keeps projects' memory; `read` only reads; `full` does whatever its person may in its projects. `-p` and `-w` keep it to some projects or workspaces, `--github` caps what its sessions' agents may do on GitHub, and `--days` makes it expire. A token with any limit never reaches the account, workspaces' people and settings, Claude connections or other tokens, so it can't make itself another. One without limits does everything its person can: keep those on people's own machines. - `fctl token` prints the token once. Making one is a sensitive action: where the instance asks people to confirm it's them, it asks in the terminal, so a person makes it, not the program. - Sessions a token starts run as its person, on their Claude subscription, and don't notify them; `fctl follow` does. - `fctl login` signs a person in through the browser (`--no-browser` prints the link to open elsewhere). It needs the person: don't use a person's sign-in for unattended work. - `FCTL_SERVER` and `FCTL_TOKEN` take precedence over what `fctl login` saved in `~/.config/fctl/` (`FCTL_CONFIG_DIR` moves that). `--server` takes precedence over both. - Revoke a token that may have leaked at once: `fctl tokens revoke NAME` or Settings → Access tokens. ## Commands that return, and commands that follow Most commands do one thing and return. Three keep running: | Command | Returns | Keeps running | |---|---|---| | `fctl run … -d` | once the session is created | | | `fctl run …` without `-d` | | following the session, also after it stops, until killed (Ctrl-C) | | `fctl attach SESSION` | | until Ctrl-C, Ctrl-D or `/detach` in a terminal | | `fctl attach --read-only SESSION`, `fctl logs -f SESSION` | at once, if the session had already stopped or failed when it connected | otherwise, also after the session stops | | `fctl logs SESSION`, `fctl show`, `fctl sessions`, `fctl send`, `fctl resume`, `fctl stop`, `fctl interrupt` | once done | | So in scripts and tool calls: - Always pass `-d` (`--detach`) to `fctl run`, then wait as below. - Use `fctl logs` without `-f`. To stream for a while, bound it: `timeout 600 fctl logs -f SESSION --json`. - While a viewer is connected (`fctl attach`, `fctl logs -f`, the web app with its tab visible), its person counts as watching the session and gets no notifications about it. Other things that wait for input: - **stdin.** `fctl run` and `fctl send` without message text, and `fctl schedules add` without `-m` or `--task-file`, read stdin to its end when it isn't a terminal. Where stdin stays open, as in some agent harnesses, they hang. Pass the text as an argument, or `-f FILE` to `fctl run`, or pipe it in yourself (`printf '%s' "$text" | fctl send SESSION`). - **Confirmations.** Deleting asks you to type the name in a terminal; without one, pass it: `fctl projects delete my-app --confirm my-app` (also `fctl workspaces delete --confirm NAME`, `fctl account delete --confirm EMAIL`, `fctl admin delete-user --confirm EMAIL`, `fctl admin delete-workspace --confirm NAME`). `fctl host uninstall` with sessions running needs `--wait` or `--force` without a terminal. - **Confirming it's you.** Where the instance asks for it, sensitive actions (making access tokens, adding or registering runners, inviting admins, exporting data, deleting accounts, projects and workspaces) fail with `verification_required` until the token confirmed within the last 15 minutes: `fctl account verify --code 123456` with a code from the person's authenticator app. Limited tokens can't confirm, and of these actions reach only deleting a project, which they leave to a person. ## Output and exit codes - With `--json`, a command prints one JSON document on stdout, in the shapes of [the HTTP API](https://fctl.sh/docs/api.md): `fctl run -d --json` and `fctl show --json` print a session (`.id`, `.number`, `.status`, `.status_reason`, `.branch`, `.pull_requests`), `fctl sessions --json` prints `{"sessions": […], "next_before": …}`. - `fctl logs --json` prints one event per line (JSON Lines), with or without `-f`: read it with `jq -s` as an array, or line by line. - Errors go to stderr as text, `error: …`, with or without `--json`. - Exit codes: 0 for success, 1 for an error (the API refused, nothing to do, a network failure), 2 for a usage error (an unknown flag, a missing argument). `--help` exits with 0. - Optional fields are left out when empty: `.pull_requests` is absent, not `[]`, until the session has one (`jq '.pull_requests // []'`). ## Naming things - A session is its id (`ses_01J…`), its URL (`https://app.fctl.sh/s/ses_…`) or `project#number` (`my-app#12`). `project#number` looks among the project's latest 200 sessions: scripts keep the id. - Projects and workspaces take their slug or id. Commands that take a project use `-p`, else the one `fctl projects use` chose; commands that take a workspace use `-w`, else `fctl workspaces use`, else your only one. So `fctl sessions` without `-p` lists only the default project's sessions when one is set; `-w` lists a workspace's. - Schedules take their id (`sch_…`) or name, memories `mem_…`, retrospectives `rtr_…`, tokens their name or `tok_…`. ## Waiting for a session A session's status says where it is: | Status | What it means | What to do | |---|---|---| | `queued` | waiting for a runner; `status_reason` says why | wait a little; if no runner is online, or none allows its image or size, tell a person | | `provisioning`, `preparing` | its sandbox is being made; the setup script runs | wait | | `running` | a turn is under way | wait | | `idle` | the turn ended: it waits for a message, or for its background tasks (`status_reason` "Waiting for 2 background tasks"), which start a turn of their own when they end | read the reply; message it, or leave it | | `paused` | its person's plan hit a usage limit; it continues by itself at `resume_at` | wait, or tell a person | | `stopping`, `stopped` | its sandbox stopped (idle timeout, `fctl stop`, its runner went away); a message resumes it | message it to carry on | | `failed` | `status_reason` says why | `fctl resume` restarts it, on your subscription | Every turn ends with a `result` event from Claude in the transcript, which holds the agent's last message. Counting them tells a turn that ended from one that hasn't started yet, which the status alone doesn't right after a message: ```sh turns() { fctl logs "$1" --json | jq -s '[.[] | select(.kind == "claude" and .data.type == "result")] | length'; } reply() { fctl logs "$1" --json | jq -s -r '[.[] | select(.kind == "claude" and .data.type == "result")] | last | .data.result'; } wait_turn() { # until the session has more than $2 finished turns, or can't go on while [ "$(turns "$1")" -le "$2" ]; do case $(fctl show "$1" --json | jq -r .status) in failed|paused) return 1 ;; esac sleep 20 done } id=$(fctl run -p my-app -d --json "Fix the flaky login test and open a pull request" | jq -r .id) wait_turn "$id" 0 && reply "$id" n=$(turns "$id") fctl send "$id" "Also add a regression test" wait_turn "$id" "$n" && reply "$id" ``` - Turns take minutes to hours. Poll every 20 to 60 seconds, not faster: each `fctl logs` reads the whole transcript (the API reads it from an event on: [Events](https://fctl.sh/docs/api.md#events)). - `fctl show SESSION --json` has the branch, the pull requests with their state and checks (`.pull_requests[] | {url, state, checks}`), the model, and counters of turns, tool calls and changed files (`.stats`). - A session that waits for a message stops after its project's idle timeout (ten minutes unless the project says otherwise); a message resumes it, in the same sandbox and conversation while its runner still has them. ## Reading a transcript Each line of `fctl logs --json` is an event: `{"seq": 21, "ts": …, "kind": "…", "data": {…}}`, with `seq` counting up from 1 and `ts` in milliseconds since the epoch. The kinds: | `kind` | `data` | |---|---| | `user_message` | what a person, fctl or a tool sent: `text`, `author`, `source` (`web`, `cli`, `api`, `remote_control`, `system`), files, and what brought it (a follow-up, a review, a ticket) | | `claude` | a message of Claude Code's stream-json output, as Claude Code wrote it: `type` is `system`, `assistant` (text and tool calls in `.message.content`), `user` (tool results), `result` (the end of a turn: `.result` is the final text, `.is_error`), `rate_limit_event` | | `status` | `status`, `reason`, `resume_at` | | `git` | the repositories' state: `branch`, `base`, `head`, `ahead`, `behind`, `files`, `uncommitted`, `pushed`, `last_commit`, `pull_requests`, and the other repositories in `repos` | | `log` | output of provisioning, the setup script and the supervisor | | `notice` | something to notice: a usage-limit pause, a runner that went away, a note added to memory, a retrospective, a follow-up held back | | `remote_control`, `interrupt` | Remote Control became available or not; someone interrupted a turn | Some useful filters: ```sh fctl logs "$id" --json | jq -r 'select(.kind == "claude" and .data.type == "assistant") | .data.message.content[] | select(.type == "text") | .text' fctl logs "$id" --json | jq -c 'select(.kind == "claude" and .data.type == "assistant") | .data.message.content[] | select(.type == "tool_use") | {name, input}' fctl logs "$id" --json | jq -s 'map(select(.kind == "git")) | last | .data' ``` `fctl logs SESSION` without `--json` prints the transcript as people read it, and `fctl search WORDS --json` finds sessions by what people and agents wrote. ## Steering a session - `fctl send SESSION "…"` reaches the agent at its next step, with `-a FILE` for files (up to 10, of 20 MB each); a stopped session resumes for it, on the Claude subscription it ran on. Each call is a new message: through the API, a `client_id` makes a retried message arrive once. - `fctl interrupt SESSION` stops the current turn; the session waits for a message. - `fctl stop SESSION` ends the session and keeps its sandbox; `fctl resume SESSION "…"` starts it again on your subscription, a failed one too, which a message doesn't. - A token that may comment or read on GitHub can only read a session whose agent started able to do more, so it can't steer an agent that pushes. - Review agents' sessions take no messages. - People can share a session with others in its workspace, and anyone who may message it can see, and have its agent hand over, what is in its sandbox: the project's secrets, its GitHub tokens and the Claude token of the person it runs for. ## Things to avoid - `fctl run` without `-d`, `fctl attach` and `fctl logs -f` in tool calls and scripts: they don't return when the session ends. - Commands that read stdin (`fctl run`, `fctl send` and `fctl schedules add` without their text) where stdin stays open. - Starting sessions in a loop. Each session spends its person's Claude plan and takes a runner's slot (four each by default); the ones runners can't take queue. Fan out to a handful at a time and wait for them. - Sending a message again because the first seemed lost: read the transcript first, or send it through the API with a `client_id`. - Passing text you didn't write as your instruction. What `fctl run` and `fctl send` carry reaches the agent as the token's person's words. Text from issues, emails, chats and webhooks goes in a when-asked schedule's `context` ([below](https://fctl.sh/docs/agents.md#from-any-tool)), which the agent gets marked as information, not instructions. - More GitHub access than the task needs. Reviews, triage and planning run with `--github comment` or `--github read`; a project's maintainers can cap every session (`fctl projects set --github comment`). - `--env` for credentials: its values are not secret. Secrets go in the project's secrets, with the value on stdin (`printenv STRIPE_KEY | fctl projects secrets set STRIPE_KEY -p my-app`), never as an argument; anything the agent runs can read both. - Pushing to a session's branch from outside while its agent works, force pushing it, or reusing it once its pull request merged: fctl pushes it after every turn until the merge, then no more. Message the agent, or start a new session. - Deleting projects, workspaces or accounts, or uninstalling and draining hosts, unless a person asked for exactly that. - Polling faster than every 20 seconds, or reading whole transcripts in a tight loop. ## Recipes ### Hand work to fctl and collect the result [Waiting for a session](https://fctl.sh/docs/agents.md#waiting-for-a-session) has the script. Variations: ```sh fctl run -p my-app -d --json --github read "Explain how sessions are scheduled, with file references" fctl run -p my-app -d --json -a screenshot.png "Make the header match this" fctl run -p my-app -d --json -f task.md --title "Move to Postgres" --branch db/postgres --base develop fctl run -p my-app -d --json --model opus --effort high "…" ``` The pull request is in `.pull_requests` once the agent opened one; its URL, state and checks come with it. ### Several tasks at once Each session has its own sandbox, branch and pull request, so independent tasks run side by side: ```sh while IFS= read -r task; do fctl run -p my-app -d --json "$task" | jq -r .id done < tasks.txt > sessions.txt fctl sessions -p my-app --active --json | jq -r '.sessions[] | "\(.number) \(.status) \(.title)"' ``` Runners take as many sessions as their capacity allows, and the rest wait in the queue. Tasks that touch the same files make pull requests that conflict; with conflict follow-ups on, each agent merges the base branch in once the other merges. ### From any tool Any program that can make an HTTP request starts agents through a schedule that runs when asked: the schedule holds the task, and the program sends what it knows as `context`. 1. A person sets up the schedule and a token: ```sh fctl schedules add "Triage an issue" -p my-app --when-asked --github comment \ -m "Triage the issue that comes with this: label it, say what is missing to act on it, link duplicates and point at the code it concerns." fctl token "Issue triage" --access run -p my-app --github comment --days 90 ``` 2. The program posts to the schedule's runs, with at most 20,000 characters of context: ```sh curl -sS -H "Authorization: Bearer $FCTL_TOKEN" -H "Content-Type: application/json" \ -d '{"context": "Issue #41: The login page hangs on Safari\n\nSteps: …"}' \ "$FCTL_SERVER/api/v1/schedules/sch_…/runs" ``` The answer is the run: `.outcome` (`started`, `skipped` or `failed`), `.reason`, and `.session.id` of the session it started. From a shell: `printf '%s' "$text" | fctl schedules run sch_… --context-file - --json`. The agent gets the task, a note that a tool started it and nobody watches it start, and the context inside `<context>`, marked as information from outside fctl, not instructions. Tools may run one schedule 20 times an hour; pausing the schedule (`fctl schedules pause sch_…`) stops them all. A tool that can't add a header to its webhooks needs a small relay that adds it and picks out the fields: a serverless function, or a no-code tool such as Zapier, Make or n8n. [Automation](https://fctl.sh/docs/automation.md#start-agents-from-other-tools) has more. ### From GitHub Actions [Automation](https://fctl.sh/docs/automation.md#review-pull-requests-from-github-actions) has a workflow that reviews each pull request with a token in the repository's secrets, keeping one session per pull request, found by its branch. The same shape triages new issues: ```yaml on: issues: types: [opened] jobs: triage: runs-on: ubuntu-latest steps: - run: | curl -fsSL https://fctl.sh/install | FCTL_NO_COMPLETIONS=1 sh echo "$HOME/.local/bin" >> "$GITHUB_PATH" - env: FCTL_SERVER: https://app.fctl.sh FCTL_TOKEN: ${{ secrets.FCTL_TOKEN }} ISSUE: ${{ github.event.issue.number }} run: | fctl run -p my-app --github comment -d "Triage issue #$ISSUE: label it, say what is missing to act on it, and point at the code it concerns." ``` Pass what comes from GitHub (titles, bodies) through `env`, never `${{ }}` inside the script, where it would run as shell. Issue bodies come from anyone: send them as a when-asked schedule's context instead of in the task. ### On a schedule ```sh fctl schedules add "Weekly dependencies" -p my-app --cron "0 9 * * 1" --tz Europe/Stockholm \ -m "Update the dependencies that have new patch or minor releases, run the tests and open a pull request." fctl schedules add "Release notes" -p my-app --at 2026-10-30T16:00+01:00 -m "Draft the release notes." fctl schedules -p my-app --json # each with .next_run_at and .last_run fctl schedules runs "Weekly dependencies" -p my-app ``` - `--cron` takes five fields or `@hourly`, `@daily`, `@weekly`, `@monthly` and `@yearly`, at most every 15 minutes; `--tz` is the machine's time zone unless given, UTC in most CI. `--at` takes `2h`, `30m`, `1d` or a time with its UTC offset. `--paused` sets it up without running it. - Runs go on the subscription of the schedule's owner, who set it up; a change by someone else moves it to theirs. A run is skipped while the last one still works, or while the owner's plan is at its limit. - Setting up or changing a schedule from a token takes `--access full`, since its sessions start after the token is gone; a `run` token runs existing ones. ### Linear 1. A workspace admin who administers the Linear workspace connects it: `fctl workspaces linear connect` (opens Linear, where they install fctl's app and choose the teams it sees). 2. A project maintainer chooses the team: `fctl workspaces linear teams`, then `fctl projects set -p my-app --tickets linear --linear-team ENG` (`--linear-project "Mobile app"` for one Linear project of it). 3. Each person links their Linear account once: `fctl account linear connect`. Then assigning an issue to fctl in Linear (which makes fctl its delegate) or mentioning fctl in a comment starts a session of the project as that person, on their subscription. The issue moves to the team's first started state, the branch carries its key (`fctl/12-eng-123-fix-the-login-loop`), and the agent's steps, pull requests and replies show in the issue's agent session, where replies reach the agent. With the `explore` label the agent replies with a plan and changes nothing until someone asks. In the sandbox, the agent reads and writes the team's issues with `fctl ticket`. ### Review agents and follow-ups ```sh fctl review settings -p my-app --on --model opus --effort high # a project owner: on their subscription fctl review settings -p my-app --instructions-file review.md fctl review settings -p my-app --bots dependabot,renovate # apps whose pull requests start reviews fctl review start -p my-app 12 # review a pull request's head now fctl review -p my-app --json # reviews and what came of each fctl projects set -p my-app --follow-ups all # comments, checks, conflicts, closed ``` Each new commit of a pull request in the project's writable repositories then gets a review; when an fctl agent opened the pull request, its session gets the findings, fixes what is real and answers in the threads. Follow-ups bring that agent back for `@fctl` comments from people who can write to the repository, failed checks, conflicts with the base branch and merges. Both need the instance's GitHub webhook ([Self-hosting](https://fctl.sh/docs/self-hosting.md#follow-ups)). ### Set up a project What a workspace admin, or a project's maintainer, can do from the CLI: ```sh fctl projects create "My app" --repo my-org/my-app -w acme --slug my-app fctl projects repos add my-org/my-lib --write -p my-app fctl projects set -p my-app --setup-script @setup.sh --env NODE_ENV=test --idle-timeout 30 fctl projects set -p my-app --image ghcr.io/acme/agent:1 # or --build .devcontainer/devcontainer.json fctl projects set -p my-app --cpus 8 --memory 16g --package-caches on fctl projects set -p my-app --github push --pr-descriptions balanced --follow-ups all fctl projects set -p my-app --tickets github fctl projects set -p my-app --model opus --effort high --instructions-file agents.md fctl projects set -p my-app --auto-pull-request on --remote-control on --connectors off printenv STRIPE_KEY | fctl projects secrets set STRIPE_KEY -p my-app fctl projects members add grace@example.com --role maintainer -p my-app fctl projects show my-app --json ``` And for a session: `fctl share SESSION grace@example.com --role viewer` lets someone else in its workspace read it (`editor` also messages its agent), `fctl unshare SESSION grace@example.com` takes it back, and `fctl archive SESSION` takes it out of the lists. ### Bring a machine online On the machine, by its owner: install a driver (Apple's `container` on macOS 26 with Apple silicon, then `container system start`; Lima; or Docker), then: ```sh curl -fsSL https://fctl.sh/install | sh fctl login app.fctl.sh fctl host install --workspace acme # or --token fctl_rt_… from the workspace's Runners tab fctl host doctor && fctl host status ``` `fctl host drain --wait` empties it for maintenance and `fctl host undrain` takes sessions again. [Getting started](https://fctl.sh/docs/getting-started.md#5-bring-a-machine-online) and [Hosts](https://fctl.sh/docs/cli.md#hosts) have the rest. ### Reports A token with `--access read` reads and starts nothing: ```sh fctl sessions --active --json fctl search "flaky test" --json fctl retro list -p my-app --outcome blocked --json fctl memory -p my-app --json fctl review -p my-app --json ``` ### Teach your agent to use fctl To let a coding agent hand work to fctl, give it `FCTL_SERVER` and a limited `FCTL_TOKEN` in its environment and a few lines in its instructions (`CLAUDE.md`, `AGENTS.md`): ```markdown ## fctl Hand longer tasks to fctl (https://fctl.sh/llms.txt) instead of doing them here: `fctl run -p my-app -d --json "<task>"` starts one; wait for its turn by counting `result` events in `fctl logs <id> --json`, and read the pull request in `fctl show <id> --json`. Never run `fctl run` without `-d`, `fctl attach` or `fctl logs -f`: they don't return. ``` ## Inside a session's sandbox An agent that fctl runs finds what it needs in its system prompt and in `~/AGENTS.md`, which `~/.claude/CLAUDE.md` imports. Its sandbox has no fctl credentials: these commands reach the control plane through fctl-agent, for its own session and project only. ```sh fctl edit --title "Fix the flaky login test" --description "Retries the redirect and tests it." fctl memory # the project's notes, now fctl memory add "The e2e tests need Chromium: run npx playwright install chromium first." fctl memory stale mem_… --reason "The script moved to xtask" fctl retro list # this session's retrospectives, with their ids fctl retro --outcome completed --worked-on "…" --approach "…" --challenges "…" --insights "…" fctl retro update rtr_… --outcome completed --challenges "…" fctl ticket list --search login # where the project's tracker is Linear or one repository's issues fctl ticket comment ENG-123 "…" fctl publish after.png # where the project publishes screenshots fctl resolve PRRT_… # agents that comment: resolve a review thread fctl review threads && fctl review submit review.json # review agents ``` - The repository is at `~/workspace`, on the session's branch; other repositories go in `~/repos/<owner>/<name>`; attached files are in `~/attachments/`. `FCTL_SESSION_ID`, `FCTL_SESSION_URL`, `FCTL_SANDBOX_CPUS` and `FCTL_SANDBOX_MEMORY_MB` say where it is. - git and `gh` are signed in with tokens for the project's repositories, as far as the session's GitHub access allows, refreshed each hour. - fctl pushes the branch after every turn until its pull request merges; agents commit as they go and never force-push. - Notes are at most 2,000 characters, a project keeps 500, and a note replaces at most 20 others. A session keeps at most 50 retrospectives and publishes at most 100 screenshots of 5 MB. In an hour, an agent comments on tickets at most 30 times, files at most 20, changes at most 60 and reads its tracker at most 300 times. - Agents in a session can't start other sessions: they hold no fctl credentials. A project secret with an access token would let them, on that token's person's subscription; treat that as giving every agent of the project that person's access. --- Source: https://fctl.sh/docs/api.md # The HTTP API The web app and the CLI do everything through one HTTP API, and so can any program: start sessions, message their agents, read their transcripts, run schedules, keep a project's memory. This page describes how to call it and the endpoints programs use most. [The CLI](https://fctl.sh/docs/cli.md) is the easiest way in from a shell; [fctl for agents](https://fctl.sh/docs/agents.md) has the patterns that go with either. The request and response bodies are the Rust types in [`crates/fctl-proto/src/api.rs`](https://github.com/hyperstructure/fctl/blob/main/crates/fctl-proto/src/api.rs), with sessions in [`session.rs`](https://github.com/hyperstructure/fctl/blob/main/crates/fctl-proto/src/session.rs) and events in [`event.rs`](https://github.com/hyperstructure/fctl/blob/main/crates/fctl-proto/src/event.rs), and as TypeScript in [`web/src/gen`](https://github.com/hyperstructure/fctl/blob/main/web/src/gen). Their comments are the reference for every field. The API grows by adding fields, which are optional: ignore the ones you don't know. ## Calling it ```sh export FCTL_SERVER=https://app.fctl.sh FCTL_TOKEN=fctl_pat_… curl -sS -H "Authorization: Bearer $FCTL_TOKEN" "$FCTL_SERVER/api/v1/sessions?status=active" ``` - **Base URL**: the instance's address followed by `/api/v1`. - **Authentication**: `Authorization: Bearer <token>`, with an access token (`fctl_pat_…`, [limited to what it is for](https://fctl.sh/docs/automation.md#access-tokens)) or the CLI's own. The web app's cookie works only from the app itself. - **Bodies** are JSON (`Content-Type: application/json`), except file uploads, which are `application/octet-stream`. - **Ids** have a type prefix: `ses_` sessions, `prj_` projects, `wsp_` workspaces, `sch_` schedules, `srn_` schedule runs, `mem_` memories, `rtr_` retrospectives, `att_` attachments, `dlv_` messages, `usr_` people, `rnr_` runners. Paths take a project's or workspace's slug or id, and a session's id (`project#number` is the CLI's; it looks the number up in `GET /sessions?project=…`). - **Times** are milliseconds since the Unix epoch. - **Absent fields**: optional fields are left out rather than sent as `null`, and lists that are empty may be left out too. - **Paging**: lists that page return a `next_before` to pass as `?before=` for the next page, and take `?limit=`. ## Errors A request that fails answers with an HTTP status and a body: ```json {"code": "not_found", "message": "Session not found"} ``` `code` is for programs, `message` for people. `field` names the request field a `validation` error is about, and `retry_after` the seconds to wait after `rate_limited`. | Status | `code` | |---|---| | 400 | `bad_request`, `validation` | | 401 | `unauthorized` (no token, or one that no longer works), `invalid_credentials` | | 403 | `forbidden` (not allowed: the role or the token's limits), `access_denied`, `two_factor_setup_required` (the instance requires two-factor sign-in and the account has none yet), `verification_required` (a sensitive action that the token must confirm first: `fctl account verify`) | | 404 | `not_found`, also for what exists but the caller may not see | | 409 | `conflict` (a message to a failed session, a resume of one that runs…), `setup_required` | | 429 | `rate_limited` | | 501, 503 | `not_configured` (the instance lacks what it needs, such as a GitHub App), `unavailable` (GitHub, a runner) | | 500 | `internal` | ## Sessions ### Start one `POST /api/v1/projects/{project}/sessions`, for people who start sessions in the project and tokens that run sessions there: ```sh curl -sS -H "Authorization: Bearer $FCTL_TOKEN" -H "Content-Type: application/json" \ -d '{"prompt": "Fix the flaky login test and open a pull request", "github_access": "push"}' \ "$FCTL_SERVER/api/v1/projects/my-app/sessions" ``` | Field | What it asks for | |---|---| | `prompt` | the task (may be empty when `attachments` has files) | | `title` | default: the task's first line; the agent renames the session once it understands the work | | `branch`, `base_branch` | default: `fctl/<number>-<title>`, from the project's default branch | | `model`, `effort` | default: the project's; `opus`, `sonnet`, `haiku`, `fable` or a model's id, and `low`, `medium`, `high`, `xhigh`, `max` | | `github_access` | `push`, `comment` or `read`: at most what the project and the token allow, which it gets when absent | | `github_issues` | `true` to reach GitHub's issues whatever the project's tracker (project maintainers) | | `attachments` | ids of files uploaded to `POST /api/v1/projects/{project}/attachments` | It answers with the session, `queued`. A session is: | Field | What it says | |---|---| | `id`, `number`, `project_id`, `project_slug`, `workspace` | who it is: `my-app#12` is `project_slug#number` | | `title`, `description` | what it works on, as its agent or people say | | `status`, `status_reason`, `resume_at` | `queued`, `provisioning`, `preparing`, `running`, `idle`, `paused`, `stopping`, `stopped` or `failed`, and why | | `created_by`, `created_at`, `started_at`, `ended_at`, `last_activity_at`, `updated_at` | who and when | | `branch`, `base_branch` | its branch and where it started from | | `pull_requests` | every pull request its agent opened or found: `url`, `number`, `repo`, `title`, `state` (`open`, `draft`, `queued`, `merged`, `closed`), `checks` (`passed`, `failed`, `pending`) | | `model`, `effort`, `github_access`, `github_issues` | what it runs with | | `stats` | turns, tool calls, changed files, additions and deletions, tokens, context | | `role`, `following`, `archived` | the caller's role in it, whether they follow it, and whether it is archived | | `schedule`, `ticket`, `review` | the schedule, ticket or reviewed pull request it started from | | `remote_control` | its Remote Control link, for the person it runs for | ### Read them - `GET /api/v1/sessions`: the sessions the caller sees, newest first, without review agents' and archived ones. `?project=`, `?workspace=`, `?status=active` (queued to stopping), `?status=ended` (stopped or failed) or one status, `?mine=1` (started by the caller), `?reviews=1`, `?archived=1`, `?limit=` (50 unless set, at most 200), `?before=` (a `created_at`). Answers `{"sessions": […], "next_before": …}`. - `GET /api/v1/sessions/{id}`: one session. - `GET /api/v1/search?q=…`: the sessions whose title, description, messages or agent's replies have the words, with `?workspace=`, `?project=` and `?limit=` (20, at most 50). Each result has the `session`, up to three `matches` (`kind`, `seq`, `text`, `ranges`) and a `count`. ### Message, interrupt, stop, resume | Request | Body | What it does | |---|---|---| | `POST /api/v1/sessions/{id}/messages` | `{"text": "…", "client_id": "…", "attachments": [...]}` | reaches the agent at its next step; resumes a stopped session on the subscription it ran on; `409` for a failed one. Answers `{"delivery_id": "dlv_…"}` | | `POST /api/v1/sessions/{id}/interrupt` | | stops the current turn | | `POST /api/v1/sessions/{id}/stop` | | ends the session; its sandbox stays for a resume | | `POST /api/v1/sessions/{id}/resume` | `{"prompt": "…", "attachments": [...]}` | starts a stopped or failed session again, on the caller's subscription; `409` while it runs | | `PATCH /api/v1/sessions/{id}` | `{"title": "…", "description": "…", "archived": true}` | renames, describes or archives it (`""` clears the description) | `client_id` is a key of your own: a message sent again with the same one is delivered once, and answers with the same `delivery_id`, so retry with it after a timeout. Files for a message go to `POST /api/v1/sessions/{id}/attachments` first. ### Events `GET /api/v1/sessions/{id}/events` returns a page of the transcript, `{"events": […], "has_more": true}`, oldest first: - `?after=<seq>`: the events after that one (from the start without it); page on with the last `seq` while `has_more`. - `?before=<seq>`: the events before that one, the newest of them. - `?limit=`: 200 unless set, at most 1,000. An event is `{"seq": 21, "ts": …, "kind": "…", "data": {…}}`. The kinds are `user_message`, `claude` (Claude Code's stream-json messages as it wrote them: `assistant`, `user`, `system`, `result`, `rate_limit_event`), `status`, `git`, `log`, `notice`, `remote_control` and `interrupt`; [fctl for agents](https://fctl.sh/docs/agents.md#reading-a-transcript) says what each holds. A turn ends with a `claude` event whose `data.type` is `result`, whose `data.result` is the agent's last message. To wait for the agent's reply to a message, note the newest `seq` before sending it, then read the events after it, page by page, until one is a `result`: ```sh API=$FCTL_SERVER/api/v1 auth=(-H "Authorization: Bearer $FCTL_TOKEN") last=$(curl -sS "${auth[@]}" "$API/sessions/$id/events?before=999999999&limit=1" | jq '.events[-1].seq // 0') curl -sS "${auth[@]}" -H "Content-Type: application/json" \ -d '{"text": "Also add a regression test", "client_id": "nightly-41-tests"}' "$API/sessions/$id/messages" reply= while [ -z "$reply" ]; do page=$(curl -sS "${auth[@]}" "$API/sessions/$id/events?after=$last&limit=1000") reply=$(jq -r '[.events[] | select(.kind == "claude" and .data.type == "result")] | last | .data.result // empty' <<<"$page") last=$(jq --argjson last "$last" '[.events[].seq] | max // $last' <<<"$page") [ -n "$reply" ] || [ "$(jq .has_more <<<"$page")" = true ] || sleep 20 done echo "$reply" ``` Check `GET /api/v1/sessions/{id}` too while you wait: a `failed` session takes no more turns, and a `paused` one waits for its person's usage limit to reset (`resume_at`). ### Follow it live `GET /api/v1/sessions/{id}/ws` opens a WebSocket (`?after=<seq>` after a reconnect) with the same `Authorization` header. Frames are JSON with a `type`: - From the server: `snapshot` first (the session, the caller's `role`, the latest `events`, `has_more_before`, the response in progress in `live`, who is watching, whether the runner is connected), then `events` (new stored events), `stream` (Claude's streamed deltas, not stored), `session` (the summary changed), `presence`, `runner_connected`, `result` (the outcome of a client frame, by its `client_id`), `error` and `pong`. - From the client: `send` (`client_id`, `text`, `attachments`), `interrupt` (`client_id`), `ping` (the apps send one every 25 seconds; a viewer that stops pinging is closed after two minutes), `typing`, and `visibility` (`{"type": "visibility", "visible": false}`: a viewer counts as watching, so its person gets no notifications about the session, until it says otherwise). The socket stays open when the session stops: close it yourself. The server closes it with 4403 once the caller may no longer see the session. ### Share and follow - `GET /api/v1/sessions/{id}/shares`; `POST` (`{"email": "…", "role": "viewer" | "editor" | "owner"}`, someone in its workspace, by the address they joined it as) and `DELETE /api/v1/sessions/{id}/shares/{user}` for the session's owners. Only the person who started it, or a workspace admin, shares ownership. - `PUT` and `DELETE /api/v1/sessions/{id}/follow`: the caller's notifications about it. - `GET /api/v1/sessions/{id}/retrospectives`: what its agent recorded. ## Schedules | Request | What it does | |---|---| | `GET /api/v1/projects/{project}/schedules` | `{"schedules": […]}`: each with `id`, `name`, `prompt`, `cron` or `at` (or neither: runs when asked), `timezone`, `model`, `effort`, `base_branch`, `github_access`, `enabled`, `state` (`active`, `paused`, `done`), `paused_reason`, `problem`, `next_run_at`, `last_run`, `owner` | | `POST /api/v1/projects/{project}/schedules` | `{"name", "prompt", "cron" or "at", "timezone", "model", "effort", "base_branch", "github_access", "enabled": false}`: set one up, on the caller's subscription (people who start sessions in the project; tokens with `full` access) | | `GET`, `PATCH`, `DELETE /api/v1/schedules/{id}` | read, change (absent fields stay; `""` for the project's defaults; `"enabled": false` pauses it; `"run_as_me": true` takes it over), delete | | `GET /api/v1/schedules/{id}/runs` | `{"runs": […], "next_before": …}`, newest first | | `POST /api/v1/schedules/{id}/runs` | `{"context": "…"}` (at most 20,000 characters): run it now, on the caller's subscription | A run is `{"id": "srn_…", "outcome": "started" | "skipped" | "failed", "reason", "trigger": "time" | "person" | "token", "by", "session": {"id", "number", "title"}, "status"}`. Runs that a token asks for give the agent the context after the task, marked as information from outside fctl, not instructions: send what another tool knows there, never in a task. People and tools run one schedule at most 20 times an hour, and a paused schedule runs not even then. [Automation](https://fctl.sh/docs/automation.md#start-agents-from-other-tools) shows a tool's request. ## Projects | Request | What it does | |---|---| | `GET /api/v1/projects` | the caller's projects (`?workspace=`) | | `GET /api/v1/projects/{project}` | one, with its primary `repo`, `default_branch`, `settings`, session counts and the caller's `role` | | `POST /api/v1/projects` | `{"workspace", "name", "slug", "description", "repo": "owner/name"` or `"create_repo": {"name", "private", "owner"}`, `"settings"}`: workspace admins | | `PATCH /api/v1/projects/{project}` | `{"name", "description", "repo", "default_branch", "settings", "archived"}`: maintainers | | `DELETE /api/v1/projects/{project}` | with its sessions, once none of them is active (`409` until then): owners. It can't be undone | | `GET`, `POST /api/v1/projects/{project}/repos`, `DELETE …/repos/{owner}/{name}` | the repositories its agents use: `{"repo": "owner/name" or "owner/*", "access": "read" | "write"}` | | `GET`, `POST /api/v1/projects/{project}/members`, `PATCH`, `DELETE …/members/{user}` | its members: `{"email", "role": "owner" | "maintainer" | "editor" | "viewer"}` (maintainers) | | `GET /api/v1/projects/{project}/secrets`, `PUT`, `DELETE …/secrets/{name}` | secrets as environment variables of its sandboxes: `PUT` takes `{"value": "…"}`; the list has names and who set them, never values (maintainers) | | `GET /api/v1/projects/{project}/tickets` | its ticket tracker as it works now, and the sessions its tickets started | `settings` in a `PATCH` replaces all of the project's settings, so send them all: read the project, change what you need, and send its `settings` back, as `fctl projects set` does (`fctl projects set -p my-app --instructions "Write the tests first." --model opus --effort high` is the same as this): ```sh API=$FCTL_SERVER/api/v1 auth=(-H "Authorization: Bearer $FCTL_TOKEN" -H "Content-Type: application/json") settings=$(curl -sS "${auth[@]}" "$API/projects/my-app" | jq '.settings | .instructions = "Write the tests first." | .model = "opus" | .effort = "high"') curl -sS "${auth[@]}" -X PATCH -d "{\"settings\": $settings}" "$API/projects/my-app" ``` The settings: `setup_script`, `instructions` (what agents are told after fctl's own), `model`, `effort`, `image` or `image_build`, `cpus`, `memory_mb`, `package_caches`, `env` (not secret), `idle_timeout_minutes`, `auto_pull_request` (agents open a pull request when they finish), `pull_request_descriptions` (`brief`, `balanced`, `full`), `github_access`, `follow_ups` (`comments`, `checks`, `conflicts`, `closed`), `tickets`, `remote_control`, `claude_ai_connectors`, `credit_people`, `credit_claude`, `publish_screenshots`, `screenshot_days`, `project_memory` and `retrospectives` ([`ProjectSettings`](https://github.com/hyperstructure/fctl/blob/main/crates/fctl-proto/src/session.rs) describes each). ### Memory, retrospectives and reviews | Request | What it does | |---|---| | `GET /api/v1/projects/{project}/memories` | `{"memories": […], "current": n}`: current notes first, newest first, then stale ones; `?session=` for one session's | | `POST /api/v1/projects/{project}/memories` | `{"text": "…", "replaces": "mem_…", "also_replaces": [...]}`: a note for later agents (editors; tokens that run sessions) | | `PATCH`, `DELETE /api/v1/memories/{id}` | `{"text", "stale": true, "reason", "duplicate_of"}`: change, mark stale or current again, delete (editors) | | `GET /api/v1/projects/{project}/retrospectives` | newest first, `?outcome=completed` / `partial` / `blocked`, `?limit=`, `?before=`; the first page counts `outcomes` | | `GET`, `PATCH /api/v1/projects/{project}/review-agents` | its review agents: `{"enabled", "run_as_me", "model", "effort", "instructions", "bots"}` (owners turn them on, on their own subscription) | | `GET /api/v1/projects/{project}/reviews` | its reviews, newest first: `state` (`running`, `passed`, `commented`, `superseded`, `stopped`, `failed`), the pull request, `findings`, `review_url` | | `POST /api/v1/projects/{project}/reviews` | `{"pull_request": "12"}` (or `owner/name#12`, or its URL): review its head now | ## Everything else The rest of the API is what the web app and the CLI use for people, workspaces, runners, Claude connections, notifications and administration: `/api/v1/workspaces/…`, `/api/v1/runners/…`, `/api/v1/me/…`, `/api/v1/admin/…`, `/api/v1/auth/…`. Access tokens with limits reach none of it. The routes are listed in [`crates/fctl-control/src/lib.rs`](https://github.com/hyperstructure/fctl/blob/main/crates/fctl-control/src/lib.rs), and [`crates/fctl-client/src/api.rs`](https://github.com/hyperstructure/fctl/blob/main/crates/fctl-client/src/api.rs), the CLI's client, calls each with its types. --- Source: https://fctl.sh/docs/automation.md # Automation Agents start when you ask, at the times you set, when someone delegates a ticket to fctl in Linear, and when your CI, a bot or another tool asks: schedules start sessions on their own, and an access token lets a workflow start them, limited to what it is for. This page shows how to set up schedules, how to delegate Linear tickets, how to start agents from other tools, such as another issue tracker, how to make a token, how to run review agents on your pull requests from GitHub Actions, and other uses: triaging issues, nightly maintenance, planning, dashboards. ## Schedules A schedule is a task that fctl starts a session for on its own: once at a set time, or again and again, every hour, day, weekday, week or month, or on any cron expression, in a time zone you choose; or only when asked, by you or another tool ([below](https://fctl.sh/docs/automation.md#start-agents-from-other-tools)). Set one up under the project's **Schedules** tab, which offers ideas to start from (dependency updates, triaging new issues, tidying the project's memory, learning from the week's reviews), or with `fctl schedules add`; **Schedule**, beside Start in the project's task box, and in the Agent Floor's in a wide window, turns what you wrote there into one. - **Each run is a session.** It is named after the schedule, works on a branch of its own and shows in the project's sessions with the schedule's mark; its page links to the schedule, and the schedule lists what each of its runs started. The agent gets the task, then a note from fctl: which schedule started it and when, its previous run, and that nobody watches it start, so that it works without waiting for answers and says plainly what only a person can decide. - **On whose subscription.** A schedule runs on the Claude subscription of the person who set it up, its owner, who follows its sessions as they follow the ones they start (unless they turned that off in Settings). A change by someone else, as one of the project's maintainers or owners, runs it on theirs from then on: nobody puts work on someone else's subscription. Running one now, from the tab or with `fctl schedules run`, starts a session on your own. - **Runs don't pile up.** A run is skipped while the schedule's last session still works on its task, and while its owner's plan is at its limit, until the limit resets; a schedule that runs once waits for the reset instead. The schedule says so, and why, in its runs. - **When its owner can't run it.** Once they can no longer start sessions in the project, their account is gone or disabled, or they have no Claude connection, fctl pauses the schedule at its next run and says why; resuming it runs it on the subscription of whoever resumes it. - **What it may do.** Its sessions start with the model, effort level and GitHub access it sets, or the project's, and never more on GitHub than the project allows now. Schedules run at most every 15 minutes, and a project keeps at most 50. ```sh fctl schedules add "Weekly dependencies" -p web --cron "0 9 * * 1" \ -m "Update the dependencies that have new patch or minor releases, run the tests and open a pull request." fctl schedules -p web # when each runs next, and what its last run did fctl schedules run "Weekly dependencies" -p web ``` ## Delegate tickets in Linear Teams that track their work in Linear hand tickets to fctl there, and fctl starts the sessions itself, without a schedule, a token or a relay: 1. **Connect Linear** on the workspace's **Linear** tab, or with `fctl workspaces linear connect`: an admin of the workspace who is an admin in Linear installs fctl's app there and chooses the teams it sees. 2. **Choose the team** in the project's **Tickets** tab, or with `fctl projects set -p web --tickets linear --linear-team ENG`, and if you like one Linear project of it. Its agents then read and write that team's tickets with `fctl ticket`, and no other tracker. 3. **Link your Linear account**, once, in Settings or with `fctl account linear connect`. Until you do, what you delegate waits an hour for you, with a button in Linear that links it. Then, in Linear, assign an issue to fctl, which makes fctl its delegate while you stay its assignee, or mention fctl in a comment. fctl starts a session of the project that takes the issue's team, on your Claude subscription, and the issue's agent session shows it working: a first thought, its steps, its pull requests, and each turn's last message, or its question. Reply there to answer it or to ask for more, and stop it there to interrupt it. Delegate a ticket with the `explore` label for a plan first: the agent finds out what it needs and replies with what it found, and changes nothing until someone asks. The ticket moves to In Progress as its session starts, and the session's branch carries its key, so with Linear's GitHub integration the pull request links to the ticket and moves it on as it opens and merges. Only people who may start sessions in the project start them this way, on their own subscription; a ticket from someone else, or for a team no project takes, gets an answer in Linear that says why ([Architecture](https://fctl.sh/docs/architecture.md#linear)). ## Start agents from other tools Any tool that can make a web request can start an agent: an issue tracker's automation, a chat bot, a deploy pipeline, a script on a server. Point it at a schedule whose task says what to do in that case, and send what the tool knows of why with it, such as an issue's title and text. Each request starts a session with the schedule's task and what came with it, on the Claude subscription of the token's person, and the schedule lists it as a run by a tool. 1. **A schedule for the case.** One that runs **when asked** has no time of its own, since the tool decides when it runs: choose When asked in its form, or `fctl schedules add "Triage an issue" -p web --when-asked --github comment -m "Triage the issue that comes with this: label it, say what is missing to act on it, link duplicates and point at the code it concerns."` Its details show the request below, with its id, ready to copy (any schedule can be run this way). 2. **A token that runs sessions.** `fctl token "Issue triage" --access run -p web --github comment --days 90`. 3. **The request.** The tool posts to the schedule's runs, with what it knows in `context` (at most 20,000 characters): ```sh curl -H "Authorization: Bearer $FCTL_TOKEN" -H "Content-Type: application/json" \ -d '{"context": "ENG-412: The login page hangs on Safari\n\nSteps: …"}' \ https://app.fctl.sh/api/v1/schedules/sch_01JC…/runs ``` Or from a shell: `fctl schedules run sch_01JC… --context-file -`, with the text on stdin. What a tool sends comes from outside fctl, often from people who can't instruct your agents, such as whoever filed an issue. So the agent gets it after the task, marked as information for the task and not as instructions. Its token can't do more than run sessions in the projects it names, and its sessions' agents can't do more on GitHub than it allows. People and tools may run one schedule 20 times an hour, and pausing it stops them all. Tools that send webhooks without a way to add a header, as some issue trackers do, need a small relay (a serverless function, or a no-code tool such as Zapier, Make or n8n) that adds it and picks out the fields to send. ## Access tokens Make one in **Settings → Access tokens**, or with the CLI: ```sh fctl token "PR reviews" --access run -p web --github comment --days 90 ``` It prints the token once. Three questions limit it: | Question | Choices | `fctl token` | |---|---|---| | What it can do | **Run sessions**: start sessions, message their agents, stop and resume the ones it started, keep projects' memory. **Read**: read projects and sessions, change nothing. **Everything**: all you can do in its projects. | `--access run`, `read` or `full` | | Where | All your projects, or some projects, or every project of some workspaces (those made later too) | `-p web` and `-w acme`, repeated | | What its sessions' agents may do on GitHub | Push, comment or read (below), for a token that runs sessions | `--github push`, `comment` or `read` | A token with any limit reaches projects and sessions only: never your account, your workspaces' people and settings, your Claude connections or your other tokens, so it can't make itself a token without limits. Only a token that runs sessions holds their agents to commenting or reading: one that does everything could change its projects' settings, and so what every agent there does. It works as you, within its limits and never beyond your own roles, until it expires or you revoke it. A token without limits, which `fctl token NAME` makes when you ask for none, does everything you can, as you do when signed in: keep those to your own machine. ```sh fctl tokens # your tokens and what each does fctl tokens revoke "PR reviews" # by name or id, at once FCTL_TOKEN=fctl_pat_… fctl whoami # what the token in use does ``` Sessions a limited token starts are yours, in your lists, on your Claude subscription, but they don't notify you: follow the ones you want with the bell on their page or `fctl follow`. ## What agents may do on GitHub Each session's agent pushes, comments or only reads, in the repositories its project may write to: | | Its GitHub token | The agent | |---|---|---| | **Push** | writes contents, pull requests, issues and workflows | commits, pushes its branch and opens pull requests, as agents always did | | **Comment** | writes pull requests and issues, reads contents | reviews, comments, labels and resolves review threads; can't push or merge | | **Read** | reads | reads the code, pull requests, issues and CI results; writes nothing | A session asks for one as it starts (the list beside the model in the task box, `fctl run --github comment`), at most what its project allows (the project's **Agents on GitHub** setting, `fctl projects set --github comment`) and what the token that starts it allows; it gets as much as they allow when it doesn't ask. GitHub enforces it: the agent's tokens can't do more. Its page says what its agent may do. GitHub lets only tokens that can push resolve review threads, so an agent that comments resolves them through fctl, `fctl resolve PRRT_…` in its sandbox, which its `~/AGENTS.md` explains. An agent that comments can still edit, close and approve pull requests and issues, as GitHub's permissions for comments allow: it is asked to approve only when its task says so. ## Review pull requests from GitHub Actions Projects have review agents built in: turn them on under the project's **Reviews** tab, or with `fctl review settings --on`, and each new commit of a pull request gets a review, with a check on the commit (outsiders' pull requests when someone asks for it); when an fctl agent opened the pull request, its session gets the findings to act on ([Architecture](https://fctl.sh/docs/architecture.md#review-agents)). Follow-ups bring an fctl agent back to its pull request without a workflow too, when someone mentions `@fctl` in a comment, a check fails, it conflicts with its base branch or it merges ([Architecture](https://fctl.sh/docs/architecture.md#follow-ups)). A workflow of your own suits reviews those don't make: ones that run the tests, or that start on a label. Here one reads each new pull request, runs its tests if that helps, and posts a review, for the project `web`, on app.fctl.sh: 1. **A token.** Make one that runs sessions in `web` alone, whose agents comment: ```sh fctl token "web PR reviews" --access run -p web --github comment --days 90 ``` 2. **A secret.** In the repository's **Settings → Secrets and variables → Actions**, add it as `FCTL_TOKEN`. 3. **A workflow.** `.github/workflows/review.yml`: ```yaml name: Review on: pull_request: types: [opened, ready_for_review, synchronize] jobs: review: if: ${{ !github.event.pull_request.draft }} runs-on: ubuntu-latest steps: - name: Install fctl run: | curl -fsSL https://fctl.sh/install | FCTL_NO_COMPLETIONS=1 sh echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Start or update the review env: FCTL_SERVER: https://app.fctl.sh FCTL_TOKEN: ${{ secrets.FCTL_TOKEN }} PR: ${{ github.event.pull_request.number }} REPO: ${{ github.repository }} run: | # One session per pull request, found by its branch. branch="review/pr-$PR" session=$(fctl sessions -p web --limit 200 --json | jq -r --arg b "$branch" '[.sessions[] | select(.branch == $b)][0].id // empty') if [ -n "$session" ]; then fctl send "$session" "New commits on #$PR. Review what changed since your last review, and resolve your review threads they address." else fctl run -p web --github comment --detach --branch "$branch" \ --title "Review #$PR" \ "Review pull request #$PR of $REPO. Read its description and diff, check it out and run the tests where that helps, and post one review with gh pr review --comment: what is wrong or risky, with suggested changes on the lines they concern. Approve nothing." fi ``` Pass what comes from the pull request, such as its title, through `env` rather than `${{ }}` in the script, where it could run as a command. 4. **The review.** Each pull request gets a session in `web`, which shows in your sessions as **Comments only**. Its agent works on one of your runners, on your Claude subscription, and posts the review as the GitHub App's bot. New commits message the same session, which reviews what changed and resolves the threads they address. What to keep in mind: - **Pull requests from forks.** GitHub gives `pull_request` workflows from forks no secrets, so their pull requests get no review. On `pull_request_target` they get secrets, and then whoever opens a pull request would start an agent on your subscription and runners: start those reviews on a label a maintainer adds (`pull_request_target` with `types: [labeled]` and `if: github.event.label.name == 'review'`). The workflow runs none of the pull request's code either way; the agent checks it out in its sandbox. - **What the agent reads can steer it.** A pull request can ask the agent to do something else. It can comment what it is asked, not push it, and its token can't reach your other projects. Its sandbox has the project's secrets, as every session's does, and it can add to the project's memory, which later agents start with, those that push too: review code from people you don't trust in a project whose secrets they may see, or that has none, and keeps no memory (`fctl projects set --project-memory off`). - **One project, an expiry.** Keep the token to the projects it serves and let it expire; revoke it in Settings → Access tokens, or with `fctl tokens revoke`, as soon as it may have leaked. ## Other uses - **Triage new issues.** On `issues: types: [opened]`, start a session that comments: `fctl run -p web --github comment --detach "Triage issue #$ISSUE: label it, say what is missing to act on it, and point at the code it concerns."` - **Nightly maintenance.** A schedule does it without a workflow ([Schedules](https://fctl.sh/docs/automation.md#schedules)): `fctl schedules add "Nightly dependencies" -p web --cron "0 3 * * *" -m "Update the dependencies that have patch releases and open a pull request."` - **Plan before building.** Choose **Read only** in the task box, or `fctl run --github read`, and ask for a plan: the agent reads the code, issues and pull requests and answers in the session, writing nothing to GitHub. A project whose agents should only ever read or comment says so in its settings. - **Dashboards and reports.** A token that reads (`--access read`) lists sessions, transcripts, retrospectives and project memory, and starts nothing: `fctl sessions --active --json`, `fctl retro list -p web --json`, `fctl logs web#12 --json`. - **Your own tools.** The API takes the token as a bearer token: ```sh curl -H "Authorization: Bearer $FCTL_TOKEN" -H "Content-Type: application/json" \ -d '{"prompt": "Review pull request #12", "github_access": "comment"}' \ https://app.fctl.sh/api/v1/projects/web/sessions ``` [Architecture](https://fctl.sh/docs/architecture.md#access-tokens) says what a limited token reaches, and [the CLI](https://fctl.sh/docs/cli.md) lists every command. --- Source: https://fctl.sh/docs/self-hosting.md # Self-hosting An fctl instance is a control plane (the API and the web app, with the Durable Objects and the D1 database that hold its state) and the hosts on your machines that connect to it. Nothing in the code is tied to fctl.sh: everything instance-specific is configuration. The control plane runs in one of two ways, with the same features, settings, hosts and apps: | | On your own machine or cluster | On Cloudflare | |---|---|---| | What runs | one container, `ghcr.io/hyperstructure/fctl-control`: the Worker on workerd, Cloudflare's open-source Workers runtime | one Worker with Durable Objects and a D1 database, on your account | | What you need | a machine with Docker, or a Kubernetes cluster; no account anywhere | a Cloudflare account with a domain on it | | HTTPS | a reverse proxy of your choice, or plain http on a network you trust | Cloudflare's, on your domain | | Backups | copies of its data directory | D1's 30 days of point-in-time history | | Made for | trying fctl out, a spare machine, a homelab, a cluster you run | a team's everyday instance, as app.fctl.sh | Set up the control plane one of the two ways, then the rest of this page (your team, GitHub, Google sign-in, email, hosts) is the same for both. ## On your own machine or cluster The image runs the Worker and the web app that run on Cloudflare, on workerd through Miniflare, with all its state (the database, every session's event log, attached files) in `/data` ([ADR 0026](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0026-the-control-plane-runs-without-cloudflare-on-workerd.md)). It listens on port 8787, runs on amd64 and arm64 machines alike, and each release of fctl publishes it. ### With Docker ```sh docker run -d --name fctl --restart unless-stopped \ -p 8787:8787 -v fctl-data:/data \ -e FCTL_PUBLIC_URL=http://192.168.1.20:8787 \ ghcr.io/hyperstructure/fctl-control docker logs fctl ``` `FCTL_PUBLIC_URL` is the address people open the app at and hosts connect to: the machine's address on your network here, `http://localhost:8787` to keep it to this machine, or the https address of a [reverse proxy](https://fctl.sh/docs/self-hosting.md#https-and-reverse-proxies) in front. The log has the link that creates the first account, the primary instance owner's, after what plain http gives up: ``` Made a master key in /data/master-key. It encrypts stored credentials and secrets: keep it with the data (or set FCTL_MASTER_KEY to it). Applied 18 database migrations fctl control plane 0.11.0 at http://192.168.1.20:8787, listening on 0.0.0.0:8787, data in /data Over plain http, anyone on the network in between can read sign-ins and tokens, and browsers offer passkeys, notifications and installing the app only over https (or on localhost): keep it to a network you trust, or put a reverse proxy with TLS in front. Create the primary instance owner's account (the link works once): http://192.168.1.20:8787/setup#token=… ``` Open it, then carry on with [your team](https://fctl.sh/docs/self-hosting.md#the-first-account-and-your-team). `fctl-data` is a volume Docker makes; a directory of yours works too (`-v /srv/fctl:/data`) once user 1000, which runs the server in the image, can write to it. [`deploy/standalone/compose.yaml`](https://github.com/hyperstructure/fctl/blob/main/deploy/standalone/compose.yaml) does the same with Docker Compose. To build the image yourself, in a clone: `docker build -f deploy/standalone/Dockerfile -t fctl-control .` ### On Kubernetes [`deploy/standalone/kubernetes.yaml`](https://github.com/hyperstructure/fctl/blob/main/deploy/standalone/kubernetes.yaml) has a StatefulSet of one, with a volume for its data, a Service and an Ingress. Set `FCTL_PUBLIC_URL` and the Ingress's host in it to your address, then: ```sh kubectl create namespace fctl kubectl -n fctl create secret generic fctl \ --from-literal=FCTL_MASTER_KEY="$(openssl rand -base64 32)" kubectl -n fctl apply -f kubernetes.yaml kubectl -n fctl logs statefulset/fctl-control ``` Keep it to one replica. Its state is SQLite files on the volume, and two servers on them would each run the same sessions: a second one waits for the first to stop, and gives up after 45 seconds. The Ingress has to pass WebSockets, as ingress-nginx, Traefik and the cloud providers' do, and bodies of up to 25 MB, for attachments. The pod reads the `fctl` Secret as environment variables: add the [GitHub App's](https://fctl.sh/docs/self-hosting.md#connect-github-optional-recommended), [Google's](https://fctl.sh/docs/self-hosting.md#google-sign-in-optional) and [Resend's](https://fctl.sh/docs/self-hosting.md#email-optional) secrets there when you use them. ### From a clone, without Docker With Rust and Node.js 22 or newer, as for [development](https://fctl.sh/docs/development.md#setup): ```sh git clone https://github.com/hyperstructure/fctl && cd fctl cargo xtask serve --public-url http://192.168.1.20:8787 ``` It builds the Worker and the web app, then runs the server the image runs, with its state in `.fctl-serve/` (`--data` for another directory). It listens on every address unless the URL is on this machine. ### Settings The server takes the settings the Worker takes on Cloudflare, as environment variables. Each can come from a file instead, `<NAME>_FILE`, as Docker and Kubernetes secrets do. | Variable | Default | What it is | |---|---|---| | `FCTL_PUBLIC_URL` | `http://localhost:8787` | The address people open the app at and hosts connect to | | `FCTL_INSTANCE_NAME` | `fctl` | Shown in the app and in emails | | `FCTL_MASTER_KEY` | made in the data directory | 32 random bytes in base64 (`openssl rand -base64 32`) that encrypt stored credentials and secrets | | `FCTL_SETUP_TOKEN` | made at each start | The token in the setup link, which works until the first account exists | | `FCTL_DATA_DIR` | `/data` in the image | Where the state lives | | `FCTL_PORT`, `FCTL_LISTEN` | `8787` (or `PORT`), `0.0.0.0` | Where it listens | | `FCTL_SEARCH_ENGINES` | `0` | `1` lets search engines index the sign-in page ([search engines](https://fctl.sh/docs/self-hosting.md#search-engines)) | | `FCTL_TRUST_PROXY` | `0` | How many [reverse proxies](https://fctl.sh/docs/self-hosting.md#https-and-reverse-proxies) in front add `X-Forwarded-For` | | `GITHUB_APP_ID`, `GITHUB_APP_SLUG`, `GITHUB_CLIENT_ID`, `GITHUB_DEFAULT_OWNER`, `GITHUB_APP_PRIVATE_KEY`, `GITHUB_CLIENT_SECRET`, `GITHUB_WEBHOOK_SECRET` | | [The GitHub App](https://fctl.sh/docs/self-hosting.md#connect-github-optional-recommended) | | `LINEAR_CLIENT_ID`, `LINEAR_CLIENT_SECRET`, `LINEAR_WEBHOOK_SECRET` | | [Linear](https://fctl.sh/docs/self-hosting.md#connect-linear-optional) | | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | | [Google sign-in](https://fctl.sh/docs/self-hosting.md#google-sign-in-optional) | | `RESEND_API_KEY`, `FCTL_EMAIL_FROM` | | [Email](https://fctl.sh/docs/self-hosting.md#email-optional) | Without `FCTL_MASTER_KEY`, the server makes a master key the first time it starts and keeps it in `master-key` in the data directory, next to what it encrypts, so a copy of the directory has both. To keep them apart, as the Kubernetes example does, set it from a secret. Once the server has made one, set that one: replacing the key makes every stored Claude credential and project secret unreadable, so the server refuses another. ### HTTPS and reverse proxies Plain http works on a network you trust. Over it, though, anyone on the network in between reads sign-ins and tokens, and browsers offer passkeys, notifications and installing the app only over https (or on `localhost`). For those, put a reverse proxy with TLS in front, which passes WebSockets and bodies of up to 25 MB, and set `FCTL_PUBLIC_URL` to its https address. [Caddy](https://caddyserver.com), for one, gets the certificate itself: ``` fctl.example.com { reverse_proxy localhost:8787 } ``` On a [Tailscale](https://tailscale.com) network, `tailscale serve --bg 8787` gives the machine an https address (`https://<machine>.<tailnet>.ts.net`) without making it public. Behind a proxy, set `FCTL_TRUST_PROXY=1` (`2` behind two of them, and so on). The control plane limits sign-in attempts and records addresses in the audit log by the address each request comes from, which a proxy puts in the `X-Forwarded-For` header it adds: without the setting, every request seems to come from the proxy. With it, the server takes the address the outermost proxy saw, and ignores what a client wrote there itself. It counts proxies rather than knowing them, so the server's port must be reachable only through them: a client that connects to it directly would choose its own address. ### Operating it - **Upgrades.** Pull the new image (or change the tag) and start the container again: the server applies the database migrations as it starts. Running sessions carry on, and their hosts reconnect. Hosts and agents tolerate a control plane one version ahead, and hosts update themselves to its version within minutes, from fctl.sh unless their `host.toml` sets another `download_base` ([Host updates](https://fctl.sh/docs/architecture.md#host-updates)); a version that isn't published there, such as a build of your own, leaves them where they are. - **Backups.** Everything is in the data directory. Stop the server and copy it, or snapshot its volume: `docker run --rm -v fctl-data:/data -v "$PWD":/backup busybox tar czf /backup/fctl-data.tgz /data`. Unless you set `FCTL_MASTER_KEY`, the copy holds the master key too: keep it as safe as the key. - **Logs.** `docker logs fctl`, or `kubectl -n fctl logs statefulset/fctl-control`: what the control plane logs, and its errors. `fctl host logs` shows a host's. - **Housekeeping.** The server runs the hourly job of [Deletion](https://fctl.sh/docs/architecture.md#data) at 17 minutes past each hour, while it runs. - **What it takes.** About 500 MB of memory and little CPU at rest. The data grows with sessions' transcripts and attached files. - **Removing it.** `fctl host uninstall` on each host first, while the control plane can still stop their sessions (`--force` stops the ones that run), then remove the container and its volume. ## On Cloudflare An instance on Cloudflare is one Worker (the API, the web app and its Durable Objects) and a D1 database, on your Cloudflare account and domain. ### What you need - A Cloudflare account with a domain (zone) on it. The **Workers Paid** plan ($5 a month) is recommended for its higher limits; a small team stays well inside what it includes. - Rust (via [rustup](https://rustup.rs); the repository pins its toolchain) and Node.js 22 or newer. - Wrangler signed in to that account: run `npx wrangler login` in the repository, or export `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`. An API token needs **Workers Scripts: Edit**, **D1: Edit**, **Workers KV Storage: Edit**, **Account Settings: Read**, and, on the zone, **Workers Routes: Edit** and **DNS: Edit** (for the custom domain). ### 1. Describe the instance ```sh git clone https://github.com/hyperstructure/fctl && cd fctl cp deploy/instances/example.toml deploy/instances/acme.toml ``` Edit `deploy/instances/acme.toml`: ```toml worker = "fctl" # the Worker's name in your account instance_name = "Acme factory" # shown in the app and in emails public_url = "https://fctl.acme.dev" # its host becomes the Worker's custom domain [d1] database_name = "fctl" database_id = "" # filled in by --create location = "weur" # optional primary location ``` Nothing in this file is secret; commit it to your fork if you like. To let search engines index the sign-in page, add `search_engines = true` ([search engines](https://fctl.sh/docs/self-hosting.md#search-engines)). ### 2. Deploy ```sh cargo xtask deploy acme --create ``` This creates the D1 database (recording its id in the instance file), builds the web app and the Worker (Rust compiled to WebAssembly), renders `crates/fctl-control/wrangler.deploy.toml`, applies the database migrations, and deploys to your domain. Run it again, without `--create`, for every upgrade. When Cloudflare's API throttles a step (it answers 429 for a while once a token or an address has made too many requests), the step runs again once the wait the API asks for is over, up to four times. ### 3. Set the secrets ```sh cargo xtask secrets acme ``` This sets, once: - `FCTL_MASTER_KEY` — 32 random bytes that encrypt stored credentials and sign state. Keep it: replacing it makes every stored Claude credential and project secret unreadable. - `FCTL_SETUP_TOKEN` — a one-time token. The command prints the setup link, `https://fctl.acme.dev/setup#token=…`. Open it to create the account of the primary instance owner; after that, the link does nothing. `--new-setup-token` prints a fresh one. Other secrets go in with `--set`, and the GitHub App's key with `--github-key` (below). ### Operating it - **Upgrades.** `git pull && cargo xtask deploy acme`; migrations apply first. Hosts and agents tolerate a control plane one version ahead, and hosts update themselves to its version within minutes ([Host updates](https://fctl.sh/docs/architecture.md#host-updates)). - **Backups.** D1 keeps 30 days of point-in-time history (Time Travel): `npx wrangler d1 time-travel restore`. For a copy, `npx wrangler d1 export`. Session event logs live in Durable Object storage, and so does each workspace's search index, which the sessions can build again: after `UPDATE sessions SET search_indexed = 0` the hourly job has them do it. - **Logs.** `npx wrangler tail fctl` streams the Worker's logs; observability is enabled in the rendered config. `fctl host logs` shows a host's. - **Costs.** The control plane is request- and storage-priced on Cloudflare; the work happens on your hardware and your Claude subscriptions. fctl never makes Anthropic API calls. - **Removing it.** `fctl host uninstall` on each host first, while the control plane can still stop their sessions (`--force` stops the ones that run), then `npx wrangler delete fctl` and `npx wrangler d1 delete fctl`. ## The first account and your team Open the setup link to create the account of the primary instance owner, then sign in and create your first workspace. Then invite your team under **Admin → People** (each address on its own, and to your workspaces at the same time), or allow them under **Admin → Access**: exact emails (which also creates invite links) or whole domains (`*@acme.dev`), whose people then sign up with Google and join the workspaces that invite them. To require two-factor sign-in of everyone, turn it on under **Admin → Settings → Sign-in** (or `fctl admin set --require-two-factor on`); it seals each authenticator app's secret with `FCTL_MASTER_KEY`. Passkeys belong to the host name in `FCTL_PUBLIC_URL`: moving the instance to another host name leaves them behind, and people add new ones. As the primary instance owner, you can also make sensitive actions, such as deleting projects, making access tokens and inviting admins, take a passkey or a code first, under **Admin → Settings → Sensitive actions** (or `fctl admin set --verify-sensitive-actions on`). It is off by default, which may suit an instance only you or a few people use; turning it off again asks you to confirm it's you. ## Connect GitHub (optional, recommended) With a GitHub App, projects link repositories, agents get short-lived tokens for their project's repositories only, and fctl can create new repositories. An instance has one app; each workspace works in the accounts it is installed on that are connected to the workspace. 1. Create an app under your organization: **Settings → Developer settings → GitHub Apps → New GitHub App**. Leave its webhook on, with the **Webhook URL** `https://fctl.acme.dev/api/v1/github/webhook` (your `FCTL_PUBLIC_URL` followed by `/api/v1/github/webhook`) and a **Webhook secret** of your own (`openssl rand -hex 32`), which you give the control plane too (step 3): GitHub then tells fctl when pull requests and their checks change, and sessions show them as they are. Set its **Setup URL** to `https://fctl.acme.dev/api/v1/github/installed` (your `FCTL_PUBLIC_URL` followed by `/api/v1/github/installed`) and tick **Redirect on update**, so that people who install it from a workspace, or give it more repositories, come back to the workspace to choose what it works in. Add a **Callback URL**, `https://fctl.acme.dev/api/v1/github/authorized` (your `FCTL_PUBLIC_URL` followed by `/api/v1/github/authorized`): a workspace's admin signs in to GitHub there to connect an account, which shows the app what they may connect. Leave **Request user authorization (OAuth) during installation** off — fctl starts the sign-in itself. Repository permissions: | Permission | Access | Why | |---|---|---| | Contents | Read and write | clone, commit, push | | Pull requests | Read and write | open and update pull requests | | Issues | Read and write | agents can read and comment on issues | | Workflows | Read and write | agents may edit `.github/workflows` | | Checks | Read and write | agents can see CI results; review agents' checks | | Actions, Commit statuses | Read | agents can see CI results | | Administration | Read and write | only for **New repository** | | Metadata | Read | required | Under **Subscribe to events**, tick **Pull request**, **Check run**, **Check suite** and **Status**: the webhook's deliveries. Tick **Issue comment**, **Pull request review** and **Pull request review comment** too, for [follow-ups](https://fctl.sh/docs/self-hosting.md#follow-ups) that bring agents back when people mention them. 2. Install it on the organization (all repositories, or the ones agents may touch, including those they only read, such as private dependencies). To let workspaces use other accounts (someone's own, a friend's organization), make the app public (**Advanced → Make public**): only then can others install it. Installing it lets the instance mint tokens for that account, but no workspace uses it until one of its admins connects it. 3. Generate a **private key** and a **client secret** (both under the app's settings), and tell the control plane about the app. The client secret signs workspace admins in to GitHub to connect accounts; reading their installations and repositories needs only the Metadata permission above, so the app asks for no more. On your own machine, set `GITHUB_APP_ID`, `GITHUB_APP_SLUG`, `GITHUB_CLIENT_ID`, `GITHUB_DEFAULT_OWNER` (the organization **New repository** creates repositories in), `GITHUB_APP_PRIVATE_KEY` (the key as GitHub gives it; with Kubernetes, in the `fctl` Secret: `--from-file=GITHUB_APP_PRIVATE_KEY=acme-fctl.private-key.pem`), `GITHUB_CLIENT_SECRET` and `GITHUB_WEBHOOK_SECRET` (the webhook's secret), and start the server again. On Cloudflare, fill in the instance file: ```toml [github] app_id = "123456" app_slug = "acme-fctl" client_id = "Iv23li…" default_owner = "acme" # where "New repository" creates repositories ``` Then store the key and the client secret and redeploy: ```sh cargo xtask secrets acme --github-key ~/Downloads/acme-fctl.private-key.pem echo "$GITHUB_CLIENT_SECRET" | cargo xtask secrets acme --set GITHUB_CLIENT_SECRET echo "$GITHUB_WEBHOOK_SECRET" | cargo xtask secrets acme --set GITHUB_WEBHOOK_SECRET cargo xtask deploy acme ``` The key is stored as PKCS#8, which WebCrypto imports (`cargo xtask secrets` and the server convert it); it never reaches a host or a sandbox. Without the client secret, a workspace keeps the accounts it has but can't connect a new one, and its GitHub tab says so. ### Pull request state and checks With the webhook's secret set, the control plane takes the deliveries GitHub signs with it, and answers others with 401 (without the secret, 501). For each pull request a session lists that one concerns, it asks GitHub what became of it, so session lists and pages show its state (open, draft, queued, merged or closed) and its checks as they change, whatever the session is doing. It also asks about each pull request a session finds, and once an hour about up to 500 open ones it hasn't heard of for six hours, which brings sessions from before the webhook up to date at the first run after you set the secret. It asks about 50 at a time, for one point of the installation's GraphQL rate limit (5,000 an hour, which your agents share). In repositories whose pull requests have so many checks that GitHub gives up on 50 at once, it asks about 10 at a time, or one at a time, for a point each. It leaves the rest for the next hour while the installation has fewer than 1,000 points left, and after five minutes of lookups. Without the secret, sessions show what their agents last looked up, without checks. The webhook also starts the reviews of projects whose review agents are on ([Architecture](https://fctl.sh/docs/architecture.md#review-agents)): a pull request opened, pushed to or marked ready gets one. Their checks need the app's **Checks** permission to be **Read and write**: for an app made before, change it under **Permissions & events**, and each installation's owners accept it (GitHub asks them). Until then reviews post, without a check, and the project's Reviews tab says why. To turn the webhook on for an app made before, edit it on GitHub: under **General**, tick **Active** under **Webhook** and fill in its URL and secret; under **Permissions & events**, subscribe to the four events above. Installations accept nothing new: the app reads pull requests, checks and commit statuses already. **Advanced → Recent Deliveries** shows each delivery and the control plane's answer: `{"ok":true,"sessions":1}` when a session heard of it. Subscribe to all four events before you rely on the webhook. When a pull request merges or closes while **Pull request** isn't subscribed, or the webhook is off, GitHub sends nothing about it, and its sessions keep showing it as it was until the hourly job looks it up again. The job skips pull requests the control plane looked up in the last six hours, and a delivery about a pull request's checks counts as a lookup: one that merges soon after its last check run shows as open for six to seven hours after that run. To bring such a pull request up to date at once, make GitHub send a delivery that names it, which makes the control plane look it up: add a label to the pull request and remove the label again (with **Pull request** subscribed), or redeliver an earlier delivery under **Advanced → Recent Deliveries** (GitHub keeps them for three days). On Cloudflare you can also have the next hourly run look up every pull request that isn't merged or closed, by clearing when the control plane last did (`fctl` is the `database_name` in your instance file): ```sh npx wrangler d1 execute fctl --remote --command \ "UPDATE pull_request_sessions SET checked_at = NULL WHERE state IS NULL OR state NOT IN ('merged', 'closed')" ``` ### Follow-ups Projects can bring their agents back to the pull requests they opened ([Architecture](https://fctl.sh/docs/architecture.md#follow-ups)): when someone who can write to the repository mentions `@fctl` or `@agent` in a comment, when a check fails, when a pull request conflicts with its base branch, when it merges or closes. New projects start with all of them on, and each project turns them on or off in its Settings, or with `fctl projects set --follow-ups` (projects made before they did keep what they stored); they need the webhook's secret, and for comments the app subscribed to **Issue comment**, **Pull request review** and **Pull request review comment**. For an app made before, subscribe to them under **Permissions & events**: installations accept nothing new, since the app reads and writes issues and pull requests already. Failed checks, conflicts and merges come with the events above: GitHub says nothing when a pull request starts to conflict, so after each merge into a repository the control plane asks it about the other pull requests there whose projects follow up on conflicts, for a point of the installation's rate limit, and again, a point at a time, while GitHub works out its answer. **Advanced → Recent Deliveries** answers `{"ok":true,"follow_ups":1}` for a comment that reached an agent, and fctl puts an 👀 reaction on it. ### Which workspace uses which account A workspace's admins connect accounts under the workspace's **GitHub** tab, or with `fctl workspaces connect`. Connecting sends them to GitHub to sign in with the app, which shows fctl what they may give the workspace: every repository of their own account, or of an organization whose every repository the app reaches and they administer all of (as its owners do); otherwise the repositories they administer. Back on the tab, they choose which of those the workspace works in. So nobody connects another account's repositories to their workspace — GitHub won't let them — and an instance admin, who holds the app's key, connects nothing into a workspace either. Installing the app on an account from the tab, or giving it more repositories there, brings them back to the same choice through the same sign-in. Repositories named with `--repo` are connected without the choice, where GitHub says they may be. New repositories go into `default_owner` when the workspace works in all of it, otherwise into an organization the workspace works in all of; the app can create repositories in organizations, not in people's own accounts. Under **Admin → GitHub** (`fctl admin github`), an instance admin sees the app's installations and the workspaces that use each, and can take an account from a workspace (`fctl admin github remove <account> <workspace>`), which its running sessions lose within the hour. Only the workspace's own admins connect it again. ### What sessions get A project has a primary repository and may list more, each read only or read and write, or let agents read every repository of the account that the app can reach (`owner/*`, within what its workspace may use); they all belong to one account, whose installation mints the tokens. Each session gets up to two, valid for an hour and refreshed through its host: | Token | Repositories | Permissions | |---|---|---| | Write | the primary repository and those listed as read and write | contents, pull requests, issues and workflows: write; checks, actions, commit statuses: read. For an agent that comments: pull requests and issues: write; contents, checks, actions, commit statuses: read | | Read only | those listed as read only, or the account's (`owner/*`); for an agent that only reads, all of them | contents, pull requests, issues, checks, actions, commit statuses: read | Issues are on them only where the project's ticket tracker says ([Architecture](https://fctl.sh/docs/architecture.md#ticket-trackers)): GitHub Issues of the repositories its agents write to, as above; of one repository, issues only on a write token for that repository alone; no tracker, or Linear, none at all. For a project that keeps its agents to one repository's issues among several it writes to, the control plane reads and writes that one's for them (`fctl ticket`), with a token for its issues alone that it revokes once used. A session a maintainer starts to reach GitHub's issues (`fctl run --github-issues`) gets them as above, whatever the tracker says. What a session's agent may do on GitHub, push, comment or read, is its own choice within its project's setting ([Architecture](https://fctl.sh/docs/architecture.md#github-access)). The app needs no more permissions for it: to resolve review threads for agents that comment, the control plane mints a token that can write the thread's repository's contents and keeps it. A review agent's session gets one token, which only reads, for the reviewed repository and the project's others. The control plane posts its review with a token of its own, for that repository alone: pull requests and checks: write, contents: read; and resolves fctl's earlier threads with one that can write contents. Neither leaves the control plane, and both are revoked once used. Agents may write only to repositories that are named: reading a whole account is as far as an owner-wide grant goes. A listed repository the installation no longer reaches (removed from it, renamed or deleted) is left out of the tokens, and the session says so; a session whose primary repository is out of reach does not start. People manage the list under the project's **Repositories** tab or with `fctl projects repos`. ## Connect Linear (optional) With a Linear app, teams that track their work in Linear delegate issues to fctl there: assigning one to fctl's agent, or mentioning it in a comment, starts a session of the project that takes the issue's team, as the person who asked, which reports back in the issue ([Architecture](https://fctl.sh/docs/architecture.md#linear)). An instance has one app, which each workspace's admins install in their Linear workspace from fctl. 1. Create an OAuth application in a Linear workspace you administer, in its API settings or from a manifest: - Its **redirect URI**: `https://fctl.acme.dev/api/v1/linear/installed` (your `FCTL_PUBLIC_URL` followed by `/api/v1/linear/installed`). Workspaces install the app there, and people link their Linear accounts. - **Public** distribution, so that Linear workspaces other than yours can install it; without it, only yours can. - Its **webhook**, at `https://fctl.acme.dev/api/v1/linear/webhook` (your `FCTL_PUBLIC_URL` followed by `/api/v1/linear/webhook`), with the resource types `AgentSessionEvent` (delegations, mentions, replies and stops), `OAuthAuthorization` (Linear says fctl's app was uninstalled from a Linear workspace, as an `OAuthApp` revocation) and `PermissionChange` (its admins changed the teams it sees). fctl uses no others: they only send it every change in every Linear workspace that installs the app. A manifest with these: ```json { "$schema": "https://linear.app/.well-known/oauth-app-manifest.schema.json", "schemaVersion": "1.0.0", "distribution": "public", "oauth": { "client_name": "acme-fctl", "redirect_uris": ["https://fctl.acme.dev/api/v1/linear/installed"], "grant_types": ["authorization_code"] }, "webhook": { "url": "https://fctl.acme.dev/api/v1/linear/webhook", "resourceTypes": ["AgentSessionEvent", "OAuthAuthorization", "PermissionChange"] } } ``` fctl asks for its scopes as workspaces install it: `read`, `write`, `app:assignable` (people assign it issues, which delegates them) and `app:mentionable` (people mention it), as Linear's own agent (`actor=app`); and `read` alone, revoked at once, when someone links their account. 2. Tell the control plane about it. On your own machine, set `LINEAR_CLIENT_ID`, `LINEAR_CLIENT_SECRET` and `LINEAR_WEBHOOK_SECRET` (the webhook's signing secret) and start the server again. On Cloudflare, add the client ID to the instance file and the secrets to the Worker: ```toml [linear] client_id = "8f6a…" ``` ```sh echo "$LINEAR_CLIENT_SECRET" | cargo xtask secrets acme --set LINEAR_CLIENT_SECRET echo "$LINEAR_WEBHOOK_SECRET" | cargo xtask secrets acme --set LINEAR_WEBHOOK_SECRET cargo xtask deploy acme ``` 3. A workspace's admin connects their Linear workspace under the workspace's **Linear** tab, or a project's **Tickets** tab: Linear asks one of its admins to install the app and to choose the teams it sees. Each project then chooses its team. People link their Linear accounts (Settings, the project's Tickets tab, or Linear's own button the first time they delegate an issue), and fctl starts sessions only for people it knows there, as them. The webhook takes deliveries signed with its secret within a minute of their time, and answers others with 401 (without the secret, 501). Each delivery is answered at once, as Linear wants within five seconds, and fctl answers in the agent session within ten. Linear's **Recent deliveries**, under the app's webhook, show what fctl answered: `{"ok":true}`. Let Linear's GitHub integration link the Linear workspace to your GitHub organizations too: sessions' branches carry their ticket's key, so it links their pull requests to the tickets and moves the tickets on as the pull requests open and merge, as each team configures. ## Google sign-in (optional) Create an OAuth client of type **Web application** in the Google Cloud console, with the authorized redirect URI `https://fctl.acme.dev/api/v1/auth/google/callback` (your `FCTL_PUBLIC_URL` followed by `/api/v1/auth/google/callback`; Google takes http ones only for `localhost`). On your own machine, set `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` and start the server again. On Cloudflare: ```toml [google] client_id = "1234-abc.apps.googleusercontent.com" ``` ```sh echo "$GOOGLE_CLIENT_SECRET" | cargo xtask secrets acme --set GOOGLE_CLIENT_SECRET cargo xtask deploy acme ``` People can then link Google under **Settings**, and allowlisted people can sign up with it. ## Email (optional) Without email, admins copy invite and password-reset links from the app. To send them, use [Resend](https://resend.com) with a verified domain. With email, the links go only to the addresses they are for, and admins don't see them: a reset link signs in as its person, and an invitation's link joins the workspaces that invited its address. On your own machine, set `RESEND_API_KEY` and `FCTL_EMAIL_FROM` (`Acme factory <fctl@acme.dev>`) and start the server again. On Cloudflare: ```toml [email] from = "Acme factory <fctl@acme.dev>" ``` ```sh echo "$RESEND_API_KEY" | cargo xtask secrets acme --set RESEND_API_KEY cargo xtask deploy acme ``` ## Search engines An instance asks search engines to keep out: its `/robots.txt` disallows everything but `/llms.txt`. To have its sign-in page found, set `search_engines = true` in its file in `deploy/instances` on Cloudflare, or `FCTL_SEARCH_ENGINES=1` for the server. `/robots.txt` then allows the sign-in page (and the early access page where there is one), `/llms.txt` and `/sitemap.xml`, and names `/sitemap.xml`, which lists them, for Google Search Console and the like. Everything else stays disallowed, and needs signing in anyway. ## Bring hosts online On each machine that runs agents (the one that runs the control plane can be one of them), follow [Getting started → Bring a machine online](https://fctl.sh/docs/getting-started.md#5-bring-a-machine-online): ```sh curl -fsSL https://fctl.sh/install | sh fctl login fctl.acme.dev # or with its scheme: fctl login http://192.168.1.20:8787 fctl host install ``` Hosts download their release from `https://fctl.sh/releases` by default; set `download_base` in `host.toml` to mirror them elsewhere (`fctl host image build` installs fctl in the default image from there too). They pull the sandbox image from `ghcr.io/hyperstructure/sandbox`; set `image` to use a copy in your own registry ([Sandbox images](https://fctl.sh/docs/sandbox-images.md)). A host runs any image a project chooses until its owner lists the ones it allows (`allowed_images`), and builds images from no repository until they list some (`allowed_builds`): set both on machines that run other people's projects ([Which images a host runs](https://fctl.sh/docs/sandbox-images.md#which-images-a-host-runs)). ## Notifications and published screenshots - **Notifications** need no setup and no push provider account: the control plane makes its Web Push (VAPID) key the first time someone opens Settings → Notifications, and keeps it in D1, sealed with the master key. Browsers allow notifications only from `https://` origins (and `http://localhost`). - **Published screenshots** stay off until an admin allows them (Admin → Settings, `fctl admin set --publish-screenshots on`) and a project turns them on. Its agents then publish screenshots for pull requests at links on your instance's domain, which anyone who has one can open until they expire (30 days unless the project chooses 24 hours to 60 days). If your instance has a privacy policy, say so there. GitHub shows them in pull requests only when its image proxy can fetch them, so only from an instance it can reach. --- Source: https://fctl.sh/docs/sandbox-images.md # Sandbox images With the Apple container and Docker drivers, each session runs in a container made from an image. This page describes the default image and how hosts get it, how a project uses an image of its own or builds one from its repository, which images a host runs, and what an image needs. ## The default image `ghcr.io/hyperstructure/sandbox` is built from [hyperstructure/sandbox](https://github.com/hyperstructure/sandbox), a public repository whose README lists the versions: Debian 13 (trixie) with git, the GitHub CLI, build tools, Python and uv, Rust, Node.js, TypeScript, Bun, Claude Code and fctl. It is published for arm64 (Apple silicon) and amd64. | Tag | Image | |---|---| | `0.5.0` | fctl 0.5.0's. Hosts of that version use it. | | `0.5` | The newest 0.5 release's. | | `latest` | The newest release's. | The sandbox repository's Image workflow builds every tag from its `main` branch, with that release of fctl and the newest Claude Code. It looks for new fctl releases every hour, so a release's image follows within the hour. fctl turns off Claude Code's auto-updater, so an image's Claude Code is as old as the image: every day, the workflow rebuilds the latest release's image when Claude Code has a newer release, and once the image is a week old, for Debian's updates. A change to the image rebuilds it too. A rebuild moves the tags; to pin one build, add its digest: `ghcr.io/hyperstructure/sandbox:0.5.0@sha256:…`. Its labels say what is inside: | Label | Value | |---|---| | `org.opencontainers.image.version`, `sh.fctl.version` | fctl's version | | `sh.fctl.claude-code.version` | Claude Code's version | | `org.opencontainers.image.revision` | the commit of hyperstructure/sandbox it was built from; images built elsewhere have none | | `org.opencontainers.image.created` | when it was built | ```sh docker image inspect ghcr.io/hyperstructure/sandbox:0.5.0 \ --format '{{ index .Config.Labels "sh.fctl.claude-code.version" }}' ``` ## How hosts get it A host whose `host.toml` sets no `image` uses the default image of its own version, so a host that updates, by itself or with `fctl self update`, moves its new sessions to the new version's image. The host pulls the image in the background when it starts, and a session that starts before it has the image pulls it itself. When the image can't be pulled, as in the hour after a release before its image is published, the host pulls `latest` instead, and sessions start from it and say so in their log. The first session after the version's image is published pulls it. The host pulls its tag again when it starts and every 6 hours, so new sessions start from the newest build of it, with the newest Claude Code. Sessions don't wait for that: they start from the build that is there, and a tag that hasn't changed costs one request to ghcr.io. `fctl host image pull` pulls it now. While the version's image can't be pulled, it pulls `latest` instead, and says so. - **Another image.** `image` in `host.toml` (or `fctl host install --image …`) sets the image for sessions whose project sets none, and a project can choose one for its own sessions ([Your own image](https://fctl.sh/docs/sandbox-images.md#your-own-image)). `fctl-agent:latest`, the name the default image had when hosts built it themselves, means the default. On another image, the host pulls it when it isn't there, only `fctl host image pull` pulls it again, and the host leaves the default image's tags alone. - **Building it.** `fctl host image build` builds the default image on the host with its driver, from the `main` branch of hyperstructure/sandbox, under the same name, so sessions use the build: for a host that can't reach ghcr.io, for instance. `--source DIR` builds from a checkout of the repository instead, to try changes to the image with fctl. The host never replaces a build with the published image, which it tells apart by its `org.opencontainers.image.revision` label; `fctl host image pull` does. - **Lima** sessions clone the `fctl-base` VM instead, which `fctl host image build` prepares with the image's provisioning script, on Lima's Debian 13 (Debian 12 with Lima older than 2.0, which lacks it). The process driver uses no image. - **Disk.** The host removes the default images that sessions no longer start from: builds that a newer one replaced, other versions' images, and `latest` once the version's image is there. It does that when it starts, after each pull and after the sweep removes stopped sandboxes, and leaves images built on the host and tags it doesn't pull, like `0.4`, alone. It removes them without force: with Docker, an image that a sandbox uses, stopped ones too, stays until the sweep removes the sandbox (`keep_stopped_hours`, or sooner on a low disk), and a build that a newer one replaced keeps the name `<tag>-replaced-<the start of its ID>` until then. Apple's `container` gives each sandbox its own copy of the image's filesystem, so the host removes them right away, and stopped sandboxes still start. ## Its layout Images built `FROM` the default image can rely on its layout, which stays the same from version to version. The sandbox repository's `check.sh` checks every build for it: - **The user** is `agent`, uid 1000, with the home directory `/home/agent` and sudo without a password: the sandbox is the boundary. Sessions run as `agent`, in `~/workspace`. - **The PATH** starts with `~/.local/bin`, where Claude Code (the native build) and uv's tools are, `~/.cargo/bin` and `~/.bun/bin`, then `/opt/fctl/bin`, where fctl copies its supervisor, fctl-agent, at session start. The image sets it, and `/etc/profile.d/fctl.sh` sets it for login shells, which setup scripts run in. fctl itself is in `/usr/local/bin`. - **The tools**: git, gh, Python 3 (`python3` and `python`) with uv, Rust through rustup with rustfmt and Clippy, Node.js 26 with npm and corepack, TypeScript (`tsc`), Bun, build-essential, cmake, pkg-config, OpenSSL's headers, ripgrep, fd, jq, ShellCheck, sqlite3, curl, wget, rsync, zip and tree. - **Rust and Bun** are `agent`'s, in `~/.rustup`, `~/.cargo` and `~/.bun`: run `cargo`, `rustup` and `bun` as `agent`. Python is marked externally managed, so packages go into a virtual environment (`uv venv`, `python3 -m venv`). In projects that share package caches, the Docker and Apple container drivers mount the project's caches at `/var/cache/fctl`, over whatever an image has there, and fctl-agent points the package managers there, for the setup script and for Claude: `npm_config_cache`, `pnpm_config_store_dir` and `pnpm_config_cache_dir` (pnpm 11; pnpm 9 and 10 get theirs in `~/.config/pnpm/rc`), `COREPACK_HOME`, `YARN_CACHE_FOLDER` and `YARN_GLOBAL_FOLDER`, `BUN_INSTALL_CACHE_DIR`, `UV_CACHE_DIR`, `PIP_CACHE_DIR`, `GOMODCACHE` and `PLAYWRIGHT_BROWSERS_PATH`, with `FCTL_CACHE_DIR` for the directory itself. Cargo keeps its downloads beside its tools, so `~/.cargo/registry`, `~/.cargo/git` and their lock files become links into the caches. What an image sets stays: its own variables, a `~/.cargo/registry` it comes with, and browsers in `~/.cache/ms-playwright`. With Docker, the host gives the caches to the image's user, `agent` or another; fctl-agent checks it can write to them, and without that, the session's package managers keep their own. An image that installs something as root switches back to `agent` at the end: ```dockerfile FROM ghcr.io/hyperstructure/sandbox:0.14 USER root RUN apt-get update \ && apt-get install -y --no-install-recommends postgresql-client \ && rm -rf /var/lib/apt/lists/* USER agent ``` ## Your own image When sessions need tools the default image lacks, such as .NET or a company's CLIs, give the project an image that has them. A setup script would install them in every session instead. This example adds the .NET 8 SDK. .NET needs ICU, which Debian has, and the SDK goes into `agent`'s home, like Rust. The PATH is set twice: `ENV` for Claude and the commands it runs, and `/etc/profile.d` for login shells, which setup scripts run in: ```dockerfile FROM ghcr.io/hyperstructure/sandbox:0.14 USER root RUN apt-get update \ && apt-get install -y --no-install-recommends libicu76 \ && rm -rf /var/lib/apt/lists/* \ && printf '%s\n' 'export DOTNET_ROOT="$HOME/.dotnet"' \ 'PATH="$HOME/.dotnet:$HOME/.dotnet/tools:$PATH"' > /etc/profile.d/dotnet.sh USER agent RUN curl -fsSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh \ && bash /tmp/dotnet-install.sh --channel 8.0 --install-dir /home/agent/.dotnet \ && rm /tmp/dotnet-install.sh ENV DOTNET_ROOT=/home/agent/.dotnet \ DOTNET_CLI_TELEMETRY_OPTOUT=1 \ PATH=/home/agent/.dotnet:/home/agent/.dotnet/tools:$PATH ``` 1. **Build it for both architectures** and push it to a registry: Apple silicon hosts run arm64 images, most others amd64. ```sh docker buildx build --platform linux/arm64,linux/amd64 \ -t ghcr.io/acme/dotnet-agent:1 --push . ``` 2. **Choose it for the project.** In Settings → Sandbox → Image, pick "An image from a registry" and enter `ghcr.io/acme/dotnet-agent:1`. From the CLI: ```sh fctl projects set -p my-app --image ghcr.io/acme/dotnet-agent:1 fctl projects set -p my-app --image "" # back to each runner's own ``` Maintainers and owners can change it. Sessions that start or resume afterwards in a new sandbox use it; a sandbox that exists keeps the image it was made from. 3. **Hosts run it if their owners allow it.** A host that lists `allowed_images` runs only the images on its list ([Which images a host runs](https://fctl.sh/docs/sandbox-images.md#which-images-a-host-runs)); one that lists none runs any. 4. **Private registries.** Hosts pull images with their own login to the registry: run `docker login ghcr.io` (with Apple container, `container registry login ghcr.io`) on each host, as the user its service runs as. fctl sends no registry credentials. For GHCR, a token with `read:packages` is enough. Lima runners don't use images: their sessions run in a clone of the VM `fctl host image build` prepared. The process driver uses none either. ## Building it from the repository A project can build its image from a Dockerfile or a dev container configuration in its repository instead, so that the environment lives next to the code and changes with it. In Settings → Sandbox → Image, pick "Built from the repository" and name the file, or from the CLI: ```sh fctl projects set -p my-app --build Dockerfile fctl projects set -p my-app --build docker/agent.Dockerfile --build-context app fctl projects set -p my-app --build .devcontainer/devcontainer.json fctl projects set -p my-app --build "" # stop building ``` The project needs a repository, and its build replaces any image it chose. Only hosts whose owners allow builds from the repository take its sessions (`allowed_builds`, [below](https://fctl.sh/docs/sandbox-images.md#which-images-a-host-runs)), and only those with the Apple container or Docker driver. When a session starts in a new sandbox, the host: 1. Reads the file at the tip of the project's default branch, with the session's GitHub token, and works out what the build reads: the Dockerfile, and the directory it builds in (the repository's root unless the setting or the dev container configuration names another). 2. Uses its last build when none of that changed since. Changes outside the directory don't count, but with the repository's root as the directory, every change does. In a busy repository, give the image a directory of its own (`--build-context docker`, say, or the `.devcontainer` directory a dev container configuration builds in by default); the builder's cache keeps the builds that follow short anyway. 3. Otherwise downloads the repository at that commit (up to 2 GiB), keeps only the directory, and builds the image with `docker build` or `container build`, from the Dockerfile's text as GitHub sent it. The session's log shows each step. Sessions that need the same build wait for the one that builds it. Builds pass no secrets: a Dockerfile that needs a token should leave that step to the setup script. 4. Tags the image `fctl-build-<project id>:<hash>`, and removes the project's earlier build, unless the runtime keeps it for a sandbox made from it (Docker does). When GitHub can't be reached, sessions start from the last build of the same setting and say so; any other problem, like a file that isn't there, fails the session with the reason. A dev container configuration's `image`, with no build, is used as it is, from its registry. **Limits.** A host builds one image at a time, and reads no Dockerfile or configuration larger than 1 MiB. A build that takes longer than `build_timeout_minutes` (60) is stopped, and an image larger than `max_build_mb` (20 GiB) is removed, where the runtime reports image sizes (Docker does); the session fails and says why. A build runs in the runtime's builder, not in a sandbox, so the project's CPU and memory settings don't apply: with Docker it gets what the Docker daemon has (on macOS, its VM), and with Apple container what its builder VM has (`container builder start --cpus 8 --memory 16g` sizes it). **What fctl reads from a dev container configuration.** `image`, or `build.dockerfile`, `build.context`, `build.args` and `build.target` (`dockerFile` and `context` at the top level in older ones), with paths relative to the configuration's directory. It leaves out features, lifecycle commands like `postCreateCommand`, `containerEnv`, `mounts` and `runArgs`, and says so in the session's log: put what they install into the Dockerfile, and what they run into the project's setup script. Sessions run as the image's user rather than `remoteUser`, so end the Dockerfile with `USER vscode`, say, to run as that user. Docker Compose configurations aren't built. The image still needs what every image needs ([below](https://fctl.sh/docs/sandbox-images.md#what-an-image-needs)): most dev container images lack Claude Code. Start the Dockerfile `FROM ghcr.io/hyperstructure/sandbox:0.14`, or install Claude Code in it as the user sessions run as (`curl -fsSL https://claude.ai/install.sh | bash`). ## Which images a host runs A project's image and its build run on machines that belong to other people, with the session starter's Claude token and the session's GitHub tokens inside them ([Security](https://fctl.sh/docs/security.md#sandboxes)). So each host's owner decides, in its `host.toml`: ```toml # Images projects may choose; the host's own `image` always runs. Unset: any. allowed_images = [ "ghcr.io/hyperstructure/sandbox", # any tag of it "ghcr.io/acme/*", # anything under ghcr.io/acme/ "ghcr.io/partner/agent@sha256:4f1c…", # this build only ] # Repositories whose Dockerfile or devcontainer.json it builds. Unset: none. allowed_builds = ["acme/*", "partner/app"] build_timeout_minutes = 60 max_build_mb = 20480 ``` The same lists from the CLI, each flag repeated for more entries (`--allow-image '*'` allows any image, `--allow-build ""` none): ```sh fctl host install --allow-image 'ghcr.io/acme/*' --allow-image ghcr.io/hyperstructure/sandbox \ --allow-build 'acme/*' ``` - **An entry of `allowed_images`** is a reference without a tag, which allows any tag or digest of that repository; with a tag, which allows that tag but not a reference that pins a digest to it, since a pull by digest ignores the tag; or pinned to a digest (`@sha256:…`), which allows only that build, and only when the project's reference names the digest too, since a tag can move. An entry that ends in `/*` allows everything under that registry path, and `*` alone allows any image. Docker Hub's names count as Docker does: `ubuntu` is `docker.io/library/ubuntu`. - **An entry of `allowed_builds`** is `owner/name` or `owner/*` on GitHub. Whoever can push to a project's default branch decides what its builds contain, including the images they start `FROM`. - **Without the lists**, a host runs any image a project chooses, as hosts did before them, and builds none. Hosts of older versions do the same. The host sends the lists to the control plane when it connects, so run `fctl host restart` after editing `host.toml`. The control plane gives a session only to a runner that runs its project's image or builds it, and the runner checks again. A session that no runner online allows stays queued and says why: "No runner allows this project's image, ghcr.io/acme/agent:1", or "No runner builds this project's image from acme/app". Admin → Runners, `fctl admin runners` and `fctl host status` show each runner's image and lists. ## What an image needs An image that doesn't start from the default one needs: - **`sh`, `tail` and git** on the PATH. The sandbox's main process is `tail -f /dev/null`. - **Claude Code 2.1.101 or later**, as `claude` on the PATH. fctl relies on requests that Claude Code sends and answers in its stream-json mode: it asks fctl for a fresh OAuth access token (`oauth_token_refresh`, from 2.1.101), and fctl turns Remote Control on (`remote_control`, from 2.1.63). fctl-agent runs `claude --version` before it starts Claude, and the session fails with a message that says so when Claude Code is missing or older. - **`gh`**, which fctl-agent asks for the pull request of the session's branch. Without it, sessions show no pull request. - **fctl** on the PATH, for agents to name their sessions, publish screenshots, keep their project's memory and record retrospectives (`fctl memory` and `fctl retro` need an fctl that has them; agents update their retrospectives where it has `fctl retro update`, and record one for each follow-up where it doesn't). Without it, sessions keep the title they start with, and their agents keep no memory. - **A non-root user with a writable home.** fctl copies its supervisor into `/opt/fctl/bin` as root at session start, and runs it as the image's user (its `USER`), in `~/workspace`. - **arm64** builds for Apple silicon hosts, and amd64 for most others. ## What fctl writes into a sandbox At session start, fctl copies fctl-agent into `/opt/fctl/bin` and starts it as the image's user, which writes: - **`~/.fctl/`**: fctl-agent's sockets, log and state, the session's GitHub tokens (mode 0600) and the git credential helper that hands them out. - **git's global configuration**: the GitHub App's bot as the author, the credential helper for github.com, `push.autoSetupRemote`, `init.defaultBranch` and a few quiet defaults. `~/.config/gh/hosts.yml` gives `gh` the token that can write. - **`~/.claude.json`**: Claude Code's onboarding and the bypass-permissions prompt marked done, the auto-updater off, the working directory trusted, and the Claude account of the person who started the session. The rest of the file stays. - **`~/.claude/settings.json`**: the image's own settings, with fctl's over them. fctl decides the permission mode (bypass), `attribution` (the trailers the project's settings ask for), `disableClaudeAiConnectors` and the auto-updater, and removes `apiKeyHelper`, `ANTHROPIC_*` and provider variables from `env`, and `permissions.disableBypassPermissionsMode`. Plugins, marketplaces, hooks, permission rules and other variables stay, and so do skills in `~/.claude/skills`. - **`~/.claude/CLAUDE.md`**: a line that imports `~/AGENTS.md`, fctl's instructions for agents, after whatever the image has there. - **`~/workspace`**, the project's repository, on the session's branch; `~/repos/<owner>/<name>` for its other repositories; and `~/attachments/` for files people attach. Claude's environment has the project's variables and secrets, `CLAUDE_CODE_OAUTH_TOKEN` and its scopes, `IS_SANDBOX=1`, `DISABLE_AUTOUPDATER=1`, `GIT_TERMINAL_PROMPT=0`, `FCTL_SESSION_ID`, `FCTL_SESSION_URL`, `FCTL_SESSION_SOCKET`, the sandbox's size in `FCTL_SANDBOX_CPUS` and `FCTL_SANDBOX_MEMORY_MB`, and in projects that keep a memory `CLAUDE_CODE_DISABLE_AUTO_MEMORY=1`. `ANTHROPIC_*` variables and the Bedrock, Vertex and Foundry switches never reach it. --- Source: https://fctl.sh/docs/architecture.md # 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, or a container ────────┐ 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 │ │ │ ├─ FilesDO (per project): attachments, │ │ │ │ published screenshots │ │ │ ├─ NotifyDO (per person): notifications │ │ │ ├─ ScheduleDO: schedules' alarm │ │ │ ├─ SearchDO (per workspace): the index │ │ │ │ that searches sessions │ │ │ └─ 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, or workerd anywhere (wasm32) | | `deploy/standalone` | The control plane without Cloudflare: a server that runs the Worker on workerd through Miniflare, its container image and examples for Docker Compose and Kubernetes. | a container, any machine | | `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), installable on phones and computers. Served by fctl-control. | browser | Three more repositories complete the picture: [hyperstructure/design](https://github.com/hyperstructure/design) (`@hyperstructure/design`: tokens, fonts, the WebGL scenes and the marks, used by `web/`), [hyperstructure/fctl-site](https://github.com/hyperstructure/fctl-site) (fctl.sh: the landing page, these docs rendered from `docs/`, the installer and release downloads) and [hyperstructure/sandbox](https://github.com/hyperstructure/sandbox) (the default sandbox image: Debian, git, gh, Python, Rust, Node.js, Bun, claude and fctl, published to `ghcr.io/hyperstructure/sandbox`). ## 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](https://fctl.sh/docs/architecture.md#claude-credentials). 2. **Your machines do the work.** The control plane (on Cloudflare, or in [a container of your own](https://fctl.sh/docs/architecture.md#deployment)) 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, but for the Claude tokens people keep on their own runners, which it is never given. 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 - Users sign in with email and password (argon2id, OWASP parameters) or with a linked Google account (OIDC, linked from Settings or created on first Google sign-in when the email is allowed), or with a [passkey](https://fctl.sh/docs/architecture.md#passkeys). With [two-factor sign-in](https://fctl.sh/docs/architecture.md#two-factor-sign-in), a code from an authenticator app follows the password. An account has one or more [email addresses](https://fctl.sh/docs/architecture.md#email-addresses), and any of them signs in. - **Access is an allowlist.** Admins add exact emails or `*@domain` patterns. Signing up requires a matching entry; removing an exact entry disables that user. Inviting an address adds its entry, which revoking the invitation removes again while no account has the address; an entry gives the role it names, so demoting or removing an admin makes those of their addresses give a member's. The first account is created through a one-time setup link (`/setup#token=…`, from the `FCTL_SETUP_TOKEN` secret) and becomes the primary instance owner. - **Early access**, where the deployment turns it on (as app.fctl.sh's does): people without an account ask for an invitation on the sign-in page, and admins invite them from a list ([Early access](https://fctl.sh/docs/architecture.md#early-access)). - Instance roles: `owner` (the primary instance owner, one per instance), `admin`, `member`. Instance admins run the instance (people, invitations, runners, GitHub, settings), not what is inside [workspaces](https://fctl.sh/docs/architecture.md#workspaces) they aren't members of; only the primary instance owner makes someone an admin, or invites or allows an address as one, and demotes admins. Workspace roles: `owner` (the primary admin, one per workspace), `admin`, `member`. Project roles: `owner`, `maintainer`, `editor` (can start sessions and message agents), `viewer`. Sessions can also be shared with people in their workspace. - Credentials are opaque random tokens with a type prefix, stored as SHA-256 hashes: `fctl_ws_` (web session cookie `__Host-fctl`, or `fctl` over plain http outside localhost, where browsers refuse the prefix: `fctl_domain::cookies`), `fctl_cli_` (CLI, issued through the device flow), `fctl_pat_` (personal access token, which may be limited: [Access tokens](https://fctl.sh/docs/architecture.md#access-tokens)), `fctl_rt_` (runner). - Cookie requests that change state must come from the app's origin (`Origin`/`Sec-Fetch-Site` checks) and carry JSON; file uploads carry `application/octet-stream`, which browsers cannot send cross-site without asking first either. WebSocket upgrades check the origin too. - **Sensitive actions take a passkey or a code**, where the primary instance owner turns that on: deleting a project, making an access token and the like ask the person to confirm it's them first, which then lasts 15 minutes for the browser, CLI or token that confirmed ([Confirming it's you](https://fctl.sh/docs/architecture.md#confirming-its-you)). ### Early access Where the deployment turns it on (`early_access` in its file in `deploy/instances`, which sets `FCTL_EARLY_ACCESS`; app.fctl.sh's does, and the standalone server doesn't take it), the sign-in page links to `/early-access`, where people without an account leave their email address (`POST /api/v1/access-requests`). The answer is the same whether the address asked before, was invited or has an account, so that it tells nobody who uses the instance: an address that has an account isn't recorded, and asking again moves the request's `asked_at`. A rate limit per client address applies, as for sign-ups. The answer has a token, which the browser keeps with the address, so that the page says it is on the list when they come back and can withdraw the request (`POST /api/v1/access-requests/withdraw`): D1 keeps its SHA-256 for an address's first request only (`withdraw_hash`), so that nobody withdraws someone else's by asking with their address, and the others' tokens look the same and withdraw nothing. Instance admins see the requests under Admin → Early access, and with `fctl admin early-access` (`GET /api/v1/admin/access-requests`): those waiting first, longest waiting first, then those invited, with whether their link still works. Inviting one is an instance invitation like any other (`POST /api/v1/admin/invites`), which works once for 14 days; the list shows its link, to copy or to send from the admin's mail app (a `mailto:` link) where the instance sends no email, and the invitation page greets them as having asked. Any invitation to the address marks its request invited, and by whom. A request goes once its address has an account (the hourly cron trigger), when an admin removes it (`DELETE /api/v1/admin/access-requests/{id}`, in the audit log) or the browser that asked withdraws it (which the audit log doesn't record), or a year after it last asked (`access_requests`). ### Search engines The Worker answers `/robots.txt` and `/sitemap.xml` itself (`fctl_domain::crawlers`; `run_worker_first` sends them to it ahead of the web app's assets). An instance is closed to search engines unless its deployment opens it (`search_engines` in `deploy/instances`, which sets `FCTL_SEARCH_ENGINES`; app.fctl.sh's does): closed, `/robots.txt` disallows everything but `/llms.txt` and there is no sitemap. Open, `/robots.txt` allows `/`, `/login`, `/early-access` (with early access), `/llms.txt` and `/sitemap.xml` itself (Google honours `robots.txt` when it fetches a sitemap), disallows the rest and names `/sitemap.xml`, which lists those pages at `FCTL_PUBLIC_URL`. Published screenshots carry `noindex` either way. ### Two-factor sign-in People turn it on in Settings, or with `fctl account two-factor enable`, with an authenticator app: TOTP (RFC 6238) with HMAC-SHA1, six digits and 30-second steps, the parameters every app takes (`crates/fctl-domain/src/totp.rs`). The CLI signs in through the browser (the device flow), so two-factor happens there ([ADR 0022](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0022-two-factor-sign-in-with-authenticator-apps.md)). 1. **Setting it up.** The control plane makes a 160-bit secret and returns it as a QR code, an `otpauth://` link (which phones open in their authenticator app) and a key to type in, together with the secret sealed for that person (AES-GCM, bound to `totp-setup:<user>`, for 15 minutes). Nothing is stored until a code from the app confirms it. Then D1 keeps the secret sealed again (`authenticator_apps`, bound to `totp:<user>`), and the first app comes with ten recovery codes, of which D1 keeps the SHA-256. Setting up an app takes the password unless this browser signed in within the last 15 minutes (with a CLI or access token, it always does). Without a password, a first app takes that sign-in, which nothing stands in for: its codes then add passkeys, so a stolen session or token must not set one up (`two_factor::setup_proof`). Replacing the app, turning two-factor off and getting new recovery codes take a current code, from the app or a recovery code. 2. **Signing in.** A sign-in with a password then starts no web session. It leaves a challenge (`sign_in_challenges`, for ten minutes) that the browser holds in the `__Host-fctl-2fa` cookie, and the page asks for a code: `POST /api/v1/auth/two-factor/verify` takes one from the app, for the time steps around now, or a recovery code, and signs in. Each works once: codes of a time step up to the last one used are refused (`last_step`). A Google sign-in asks for a code when the person chose that, or the instance's admins did for everyone, and lands on `/two-factor`; otherwise Google's own sign-in is enough. A password reset asks for a code too, and its new password takes effect only once the code checks out. 3. **Wrong codes.** Five in a row make codes from the app wait a minute, and each further one twice as long, up to an hour (`fctl_domain::two_factor::lockout_ms`), besides the rate limits per address and account. Recovery codes, which can't be guessed, still work meanwhile, so whoever has the password can't lock the person out, and the person gets an email (when email is set up): whoever it was knew the password. A workspace's admins require it of its members (the workspace's Settings, or `fctl workspaces set --require-two-factor on`): for them, it works as the instance's requirement below, except that it closes the workspace to a member without a second factor, not their account (`two_factor::workspace_blocked`). Their sign-ins ask for a passkey as the second factor too, and their last factor can't go while they are in it (`two_factor::with_workspaces`). While the instance requires it, every workspace does, and their admins can't turn it off (`two_factor::workspace_requires`, `workspace::check_two_factor`). Someone without a second factor then finds their whole account closed, not only the workspace; once the instance stops requiring it, each workspace's own choice applies again. Admins require it of everyone (Admin → Settings → Sign-in, or `fctl admin set --require-two-factor on`): of every account that signs in with a password and, once Google sign-ins ask for codes too (`--two-factor-google`), of every account. There, a [passkey](https://fctl.sh/docs/architecture.md#passkeys) counts as the second factor too, after the password or Google, in place of a code. An account it is required of that has neither an authenticator app nor a passkey can do nothing else until it has one: the control plane answers its other requests, from browser sessions and CLI and access tokens alike, with `two_factor_setup_required` (`api::two_factor::open_while_required`), and the web app shows the page that sets it up instead of the app. Admins reset it for someone who lost their phone and recovery codes (`fctl admin reset-two-factor`; only the primary instance owner resets an admin's, and nobody theirs), which removes their authenticator app and recovery codes, and their passkeys where those are the second factor required (unless a passkey is their only way in). Turning it on, replacing the app, turning it off, an admin's reset and a sign-in with a recovery code go in the audit log, and email the person when email is set up. ### Passkeys Passkeys are WebAuthn credentials that the person's device or password manager keeps and finds by itself (discoverable), and that sign in after a fingerprint, face or screen lock: two factors in one, which no other site can use. fctl asks for no attestation, takes ES256, EdDSA and RS256 keys, and belongs to the host name of `FCTL_PUBLIC_URL` (its relying party id). `fctl_domain::webauthn` checks what browsers send (with a small CBOR decoder, `fctl_domain::cbor`), and WebCrypto checks the signatures (`crypto::verify_passkey`). - **Making one** (Settings → Passkeys, the page that requires two-factor, or the card after a sign-in without one): `POST /api/v1/me/passkeys/options` returns WebAuthn's creation options, with a challenge of 256 random bits that works once, for ten minutes (`passkey_challenges`, by its SHA-256). It takes the password unless this browser signed in within the last 15 minutes, or without a password a code from the authenticator app: a passkey signs in without either, so a stolen session must not add one. `POST /api/v1/me/passkeys` checks the answer (the challenge, the origin, the relying party, user presence and verification) and keeps the public key (`passkeys`). The authenticator's model (AAGUID) names it: "1Password", "Apple Passwords"; otherwise the browser's suggestion, "Chrome on macOS". - **Signing in.** The sign-in page asks for options on load (`POST /api/v1/auth/passkey/options`), and again before they run out. Their challenge is signed with the master key rather than stored, so that page loads write nothing; it is recorded once used, so it works once. The browser offers the person's passkeys in the email field's suggestions (conditional mediation, `autocomplete="username webauthn"`), and **Sign in with a passkey** opens its passkey sheet with the same options. `POST /api/v1/auth/passkey` checks the signature with the stored key and the counter, which must move on unless it stays 0 (synced passkeys' do), and signs in without a second factor. - **As a second factor**, where two-factor is required, or for someone who has an authenticator app too: the step after the password offers it (`POST /api/v1/auth/two-factor/passkey`, then `POST /api/v1/auth/two-factor/verify` with the passkey's answer). - **Confirming it's you** before sensitive actions, where the instance asks for it ([Confirming it's you](https://fctl.sh/docs/architecture.md#confirming-its-you)): `POST /api/v1/auth/verify/passkey`, then `POST /api/v1/auth/verify` with the passkey's answer. - **Signals.** Where browsers have them, the app tells the person's passkey provider which passkeys the account has, what the account is called, and which passkey fctl doesn't know any more (`signalAllAcceptedCredentials`, `signalCurrentUserDetails`, `signalUnknownCredential`), so that it stops offering removed ones. Settings gives the account's email and name only when they changed since this browser last gave them or made a passkey with them (it keeps a hash of them for each account): Apple's Passwords notifies the person of each time, changed or not. Adding and removing a passkey go in the audit log, and adding one emails the person when email is set up. A passkey that is the account's last way in, or the second factor the instance requires, can't be removed until another takes its place ([ADR 0023](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0023-passkeys-sign-in-on-their-own.md)). ### Confirming it's you Where the primary instance owner turns it on (Admin → Settings → Sensitive actions, or `fctl admin set --verify-sensitive-actions on`; off by default), sensitive actions take the person's passkey or a code from their authenticator app first, so that a stolen browser session, CLI or access token can't do them alone ([ADR 0039](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0039-sensitive-actions-take-a-passkey-or-a-code.md)). `fctl_domain::verify::Sensitive` lists them: - inviting someone to the instance as an admin, or allowing an address as one, making someone an admin, and handing over the primary instance owner's role; - turning off what makes people prove it's them: requiring two-factor sign-in, of the instance or a workspace, asking for codes on Google sign-in, and this confirmation itself; resetting someone's two-factor sign-in, and removing a passkey; - adding one of your runners to a workspace, or letting it run everyone's sessions there, registering a new one for a workspace (as `fctl host install` does, which asks in the terminal), and replacing the token of one that serves a workspace; - making someone a workspace's admin, or inviting them as one, and handing over or giving a workspace's primary admin role; - making an access token, and approving a CLI's sign-in; - exporting your data, and deleting your account, someone else's, a project or a workspace; - making someone a password reset link on an instance that sends no email, where the link comes back to the admin. Only the primary instance owner turns it on or off, and turning it off is one of those actions (`verify::check_verify_setting`). Turning off your own authenticator app, replacing it and new recovery codes take a current code already, and adding a passkey or an app the password or a recent sign-in ([Two-factor sign-in](https://fctl.sh/docs/architecture.md#two-factor-sign-in)). 1. **Who it asks.** People with a passkey or an authenticator app (`verify::required`). People with neither have nothing to confirm it with, and aren't asked: requiring two-factor sign-in gives everyone one. 2. **Where.** Each sensitive action's handler checks it once the action is allowed and checked, right before it changes anything (`Ctx::verified`), so that nobody confirms for something that then fails; actions whose request decides (a role, a setting turned off) ask only when they are sensitive. A browser, CLI or token that hasn't confirmed within the last 15 minutes gets `verification_required` (HTTP 403), which says what to confirm and with what. D1 keeps when each token last confirmed (`tokens.verified_at`), and the token lookup reads it with the token, so the check costs no query. 3. **How.** `POST /api/v1/auth/verify` takes a passkey's answer to `POST /api/v1/auth/verify/passkey`'s options (a challenge for that person, `verify`, which the device must verify them for), a code from the app or a recovery code, used up and counted towards the wait after wrong ones as at sign-in. The token that sent it counts as confirmed for 15 minutes, and the audit log records it (`user.verified`). A browser that signs in with a passkey, or with a second factor after the password or Google, counts as confirmed from its sign-in (`auth::start_web_session`). `GET /api/v1/auth/verify` says whether sensitive actions ask the caller, with what, and until when they count as confirmed. 4. **The web app** answers `verification_required` with one dialog, the passkey first where the browser can use one, then the app's code or a recovery code, and sends the request again once the person confirmed (`web/src/lib/api.ts`, `components/two-factor/verify.tsx`); requests that wait at the same time share it. The export, a download the browser makes, asks first. 5. **The CLI** asks in a terminal, and sends the request again: for a code from the app, or, for someone with passkeys, to approve a code in the browser, as `fctl login` does (`POST /api/v1/auth/verify/device`, the `/device` page). Approving that code confirms the CLI's token instead of signing a new one in, and the device flow's poll answers `verified`; such a code is its person's alone. Without a terminal the command fails with the server's answer, and `fctl account verify --code` confirms first. Access tokens with limits can't confirm; of these actions they reach only deleting a project, which they leave to a person. ### Access tokens People make personal access tokens (`fctl_pat_`) for scripts, CI and bots in Settings → Access tokens or with `fctl token`, and list and revoke them there or with `fctl tokens`. A token may be limited, in three ways ([ADR 0037](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0037-access-tokens-and-sessions-limit-what-agents-do-on-github.md)), which D1 keeps with it (`tokens.scope`, `fctl_domain::limits::Limits`): - **What it does** (`access`): `read` reads projects and sessions as a viewer does, with their transcripts, changes, memory and retrospectives; `run` also starts sessions, messages and interrupts their agents, stops and resumes the sessions its person started and keeps projects' memory, as an editor does; `full` does whatever its person may in its projects. - **Where** (`places`): every project its person sees, or the projects of some workspaces (those made later too) and some projects. - **What its sessions' agents may do on GitHub** (`github_access`), for a token that runs sessions: the sessions it starts get at most that ([GitHub access](https://fctl.sh/docs/architecture.md#github-access)), and a session whose agent started able to do more is read only to it, so that a token for review agents can't steer an agent that pushes, also once its project holds that agent lower, while its sandbox still has the tokens it started with (`SessionRow::started_github`). A token that does everything can't have this limit (`limits::check`): it could change its projects' settings, and so what every agent there does. A token with any limit reaches projects and sessions alone. The router refuses it everything else before a handler runs (`limits::allows`): the account and its sign-in methods, Claude connections, workspaces' people, settings, runners and GitHub accounts, notifications, other tokens and administration, so that a token handed to CI can't make itself another. `GET /auth/me` shows it whose it is (their name and primary address) and the token itself, nothing more. Its role in a project is its person's, at most a viewer's (`read`) or an editor's (`run`); in a session, its person's with their project role so capped, and at most a viewer's for a token that reads (`limits::session_role`). Lists of projects, sessions and workspaces, and searches of sessions, keep to its places in SQL (`access::places_sql`), so that their pages stay full, and sockets opened with it check its limits again as they check its person ([Viewers](https://fctl.sh/docs/architecture.md#viewers)); it opens no feed. Limits that don't read as limits, as a newer version's might, reach nothing. A place that is deleted, or that its person no longer sees, drops out of its reach, and a token whose places are all gone reaches nothing. Its limits hold however it comes, as a bearer token or in a cookie. Browser sessions and the CLI's sign-ins have none. A control plane from before limits ignores them and makes a token without any, so `fctl token` revokes such a token and says so. The audit log records each token's limits as it is made, and which token started a session. ## Workspaces A workspace is the people who work together, their projects, the runners their sessions run on and the GitHub accounts their projects use ([ADR 0024](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0024-workspaces-hold-people-projects-runners-and-github-accounts.md)). It has a name, an address (`/w/<slug>`), a color and an avatar image (at most 256 KB, which the web app makes 256 px square; `GET /api/v1/workspaces/{id}/avatar`, at a URL that changes with it). Someone can be in at most five (`fctl_domain::workspace::MAX_WORKSPACES`), and the primary instance owner in any number (`workspace_limit`, [ADR 0058](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0058-the-primary-instance-owner-can-be-in-any-number-of-workspaces.md)): admins are held to five as members are, and an owner who hands the role over keeps every workspace they are in but joins no more while they are in five or more. Anyone on the instance creates one, as its primary admin. - **Who sees what.** Every project belongs to one workspace, and only its members see it (`fctl_domain::workspace::project_role`): its admins own every project; a member has their role as the project's member or the workspace's base role (`none`, `viewer`, `editor`, `maintainer`), whichever is higher. New workspaces start with `editor`. Starting a session, a share or a project role counts for nothing once someone leaves the workspace, or while its two-factor requirement closes it to them. Starting a session makes someone its owner only while they may start sessions in its project: made a viewer of it, or taken out of it, they have the session as its viewers do, or not at all, so that they neither steer its agent nor resume it with the project's secrets and new tokens (`fctl_domain::perm::session_role`). Nobody shares a session with themselves, and a share someone gave themselves before counts for nothing: it would outlast the role that let them make it. The project and session queries join the caller's membership (`model::PROJECT_SELECT`, `PROJECT_VISIBLE`). - **Roles.** Admins invite and remove people, change roles, choose the runners and GitHub accounts, create its projects ([ADR 0035](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0035-only-a-workspaces-admins-create-its-projects.md)), and change the settings; the primary admin also deletes the workspace and hands the role to another admin. Nobody else can demote or remove the primary admin, who leaves only after handing the role over (`fctl_domain::workspace::check_*`). Once the primary admin's account is disabled or gone, an instance admin gives the role to one of its members (`fctl admin set-primary`), themselves only where they are one of its admins already, since they can disable anyone (`check_assign_primary`). - **Invitations** (`workspace_invitations`) are for an address and last 30 days. Whoever has it (an account's, or connected to one) sees it on their Agent Floor and in `fctl workspaces`, and joins as that address. A workspace's admins invite any address and get the same answer whether it can sign in to the instance, has an account or is another address of a member, so that inviting tells them nothing about who uses the instance: the email goes out after the answer, and only to an address that can sign in and isn't a member's. One the instance doesn't admit yet waits for an instance invitation; instance admins, who see who can sign in anyway, are told so, and send the instance's invitation together with their workspaces'. People invited before they had an account join when they make it, or connect the address, as far as the limit and two-factor allow; the rest keep waiting. - **Your preferences.** Each member chooses the Claude connection their sessions in it run on (`workspace_members.credential_id`), else their default one, so that a company's seat pays for the company's work. They also put their workspaces, and each one's projects, in the order they want, by dragging them in the app's sidebar or with `fctl workspaces order` and `fctl projects order` (`PUT /api/v1/me/workspace-order`, and `project_order` in `PATCH /api/v1/workspaces/{id}/membership`). The membership keeps it (`workspace_members.position`, `project_order`), and lists of workspaces and projects come in it (`model::WORKSPACE_ORDER`, `PROJECT_ORDER`): what someone hasn't placed follows, oldest first, so nothing moves unless they move it. Which workspaces they fold in the sidebar stays in their browser, and so does a copy of what the sidebar last showed them (names, colors and avatars, their role, each project's active sessions; `web/src/lib/sidebar.ts`): when the app opens, the sidebar shows it at once, until the lists of workspaces and projects have both come. Signing out forgets it. - **Audit log.** What happens in a workspace (people, roles, runners, GitHub accounts, settings, projects, sessions) goes in its log (`audit_log.workspace_id`), which its admins read; the rest is the instance's. - **Feeds.** A feed's identity holds the projects its person saw when it opened, and the workspaces where they see every project (`internal::FeedIdentity`). When someone's workspaces or project roles change, FleetDO recomputes it on their sockets (`/workspaces-changed`) and sends `workspaces`, after which the app fetches them again; runners and their updates go only to the people of their workspaces, their owners and instance admins. It also checks a feed's token and recomputes what it shows on its pings, at most once a minute (`recheck_feed`), and closes a feed whose token no longer signs anyone in, as SessionDO does with [viewers](https://fctl.sh/docs/architecture.md#viewers). - **Deleting** a workspace (its primary admin, or an instance admin, typing its name) deletes its projects with their sessions and files, as deleting a project does, once none of its sessions runs. ### Email addresses Each address is invited to the instance on its own. Accepting an invitation while signed in (`POST /api/v1/invites/{token}/connect`) connects its address to that account instead of making a new one (`user_emails`). That takes a browser sign-in from the last 15 minutes, and the account's primary address is emailed about it, since the address then signs in and receives password reset links: an invitation link forwarded to someone else would otherwise give its holder their account. Every address signs in, and has its own commit email ([Commits](https://fctl.sh/docs/architecture.md#commits)); the account's primary one (`users.email`) is where fctl writes to the person. A workspace knows its member by the address they joined as (`workspace_members.email`), the only one its people see: project members, shares and invitations look people up by it (`api::auth::member_by_email`). Disconnecting an address (`DELETE /api/v1/me/emails/{email}`) leaves the workspaces joined as it. ### GitHub accounts The instance has one GitHub App. A workspace uses an account the app is installed on once one of its admins connects it (`workspace_github`), with every repository the app reaches there or only some ([ADR 0029](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0029-workspaces-connect-github-accounts-with-a-github-sign-in.md), [ADR 0031](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0031-admins-choose-a-github-accounts-repositories-after-signing-in.md)). Connecting takes a GitHub sign-in: the admin is sent to GitHub to sign in with the app's OAuth client (PKCE), and the user access token that comes back decides what the workspace may get, because the app's tokens can write and GitHub's setup redirect can name anyone's installation. fctl reads which of the installation's repositories the person can see and administers (`GET /user/installations/{id}/repositories`), and `fctl_domain::github::offer` turns that into what they may connect: every repository of their own account, or of an organization whose every repository the app reaches and they administer all of (as its owners do); otherwise the repositories they administer, by name. The installation is found among those the token can see (`GET /user/installations`), never by id alone, so connecting reveals nothing about where else the app is installed; a sign-in that names no account offers every installation the token sees (at most 20). fctl uses the token once and revokes it. What it showed waits ten minutes for the admin to choose from (`github_offers`, theirs alone): the workspace's GitHub tab lists it, with what the workspace works in there now checked, else all of it, and connects what they choose (`POST /api/v1/workspaces/{id}/github/offers/{account}`), which must come from the offer (`fctl_domain::github::choose`). A connection records who connected it and the account they signed in as (`api::github`). Repositories named before the sign-in (`fctl workspaces connect acme --repo web`) are connected at once instead, as far as GitHub says the admin may (`fctl_domain::github::grant`). The sign-in's details ride in a signed cookie (`__Host-fctl-github`), its PKCE state checked on the way back. A connection is to an account by GitHub's id (`workspace_github.account_id`), since a login can change hands: an account renamed or deleted, and its name taken by someone else, who installs the app there. The control plane mints a session's tokens, links and creates repositories, and tells sessions what pull request lookups and webhooks say only through the installation on a login that still belongs to the account the workspace connected (`workspaces::installation`, `fctl_domain::github::binding`). Otherwise sessions fail, and links refuse, saying that the workspace's admins connect the account again, by its name now. A connection from before ids were kept takes the account of the installation it recorded, when GitHub still has it, else the one that has the login, the first time it is used. Installing the app from a workspace, or giving it more repositories, goes through `/api/v1/github/install`, which keeps the workspace in a signed cookie (`__Host-fctl-github-install`, for an hour) on the way to GitHub. GitHub sends people to the app's setup URL (`…/api/v1/github/installed`) after they install the app, with that state, and after they change an installation (Redirect on update), without it: there the cookie stands in. The GitHub sign-in for the installation GitHub names follows, and the browser comes back to the workspace's GitHub tab to choose. A setup URL reached without either (the app installed or changed on GitHub itself) goes to the GitHub tab of the person's workspace that uses the installation, or of the only one they're an admin of, where they connect with GitHub. Signing in to GitHub needs the app's client ID and secret (`GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`), the callback URL `…/api/v1/github/authorized` and, to come back from installing it, the setup URL with Redirect on update; without the first two a workspace keeps the accounts it has but connects no new one (`can_connect` is false). Instance admins connect and approve nothing: Admin → GitHub lists the installations and the workspaces that use each (`fctl admin github`), and an instance admin can take an account from a workspace, not give one. Project repositories must be within the workspace's connections (`fctl_domain::repos::check_scope`), and every token a session gets is minted within them (`repos::narrow`), for the runner the session runs on only, so that disconnecting an account takes it from running sessions at their next refresh, within the hour. New repositories are created in an organization the workspace uses all of. ### Runners and workspaces A runner belongs to the person who registered it (`runners.created_by`) and runs the sessions of the workspaces it is added to (`runner_workspaces`): everyone's there, or only its owner's (`personal`). A workspace trusts a runner for everyone's sessions with their code, secrets and the tokens of the people who start them, so its owner adds one only as an admin of the workspace; a member adds theirs for their own sessions, which show it nothing they couldn't read in the session ([ADR 0036](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0036-members-run-their-sessions-on-their-own-runners.md)). Admins add theirs for everyone's sessions unless they ask otherwise (`--personal`), and adding a runner again changes whose; a demoted admin's runners run only their own sessions from then on. A workspace's admins take any runner out of it; its owner and instance admins rename, drain, disable and remove it. A session runs as the owner of its Claude connection. A member may keep their sessions in a workspace on runners they registered (`workspace_members.own_runners`, **Only on my runners** in its Settings, `fctl workspaces own-runners`): no one else's machine then sees their Claude token or their sessions' work. A connection whose token they keep on their runners keeps their sessions there in every workspace, on those of their runners that keep it ([Tokens on your runners](https://fctl.sh/docs/architecture.md#tokens-on-your-runners)). FleetDO reads which workspaces each runner serves, and for whom, before it dispatches, and starts a session only on a runner of its workspace that runs the sessions of its person (`Slot.serves`, `fctl_domain::schedule::runs_for`). A session that no online runner takes says why: the workspace has no runner, none online, only its members' own, or none of its person's, when they keep theirs on their own runners. A runner serves a workspace while its owner is an active member there (`serves!`). Once it stops running a session (taken out of the workspace, its owner left, was disabled, or the session's person turned on own runners), the session stops and resumes on another runner, whatever its host does: FleetDO stops it (`/runner-changed`), closes the runner's uplink to it, and the runner gets no credentials, uplinks, files or screenshots for it any more (`spec::runner_serves`). Disabling a runner or replacing its token closes its control connection and its sessions' uplinks. A Claude sign-in runs on one of the person's own runners when one is online, else on a runner that runs their sessions in one of their workspaces. Hosts update themselves to the version the control plane runs ([Host updates](https://fctl.sh/docs/architecture.md#host-updates)). The Agent Floor tells the person who registered a runner when it is online with an older fctl than the control plane and its hello says it doesn't update itself, or says nothing of it: the version in its hello against the one `GET /api/v1/instance` reports, which on app.fctl.sh is the latest release, since each release redeploys it. It shows the command that updates the runner, `fctl self update`. Skipping the notice hides it for that version of the control plane, in that browser (localStorage, per person). ## Data **D1** (`crates/fctl-control/migrations`) holds relational state: users and their email addresses, identities, two-factor sign-in (sealed authenticator secrets, hashed recovery codes, sign-ins waiting for a code), passkeys' public keys and their challenges, allowlist, early access requests, auth tokens (what access tokens are limited to, and when each last confirmed it's its person), device codes, workspaces (with their avatar images), their members, invitations, GitHub accounts (and what GitHub sign-ins offer to connect, while admins choose) and runners, projects, their members and repositories, sessions (summary rows, and whether the search index has all of each), which sessions list which pull request, projects' review agents and their reviews, shares, who follows which session, notification settings and browsers (push subscriptions), runners, encrypted credentials and project secrets, the latest plan usage of each Claude credential, the links of published screenshots, projects' memories and sessions' retrospectives, projects' schedules and their runs, the Linear workspaces fctl's app is installed in (with their sealed tokens), the fctl workspaces connected to each, people's Linear accounts, Linear's agent sessions and the fctl sessions they reach, what agents wrote in their trackers in the last hour, the instance's settings and sealed Web Push key, 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. **FilesDO** (one per project, SQLite storage) holds the files people attach to messages, in 1 MiB chunks, and which session each was sent in (see [Attachments](https://fctl.sh/docs/architecture.md#attachments)), and the screenshots its agents publish until they expire (see [Published screenshots](https://fctl.sh/docs/architecture.md#published-screenshots)). **NotifyDO** (one per person, SQLite storage) holds their notifications while they wait to go out, and forgets them once sent (see [Notifications](https://fctl.sh/docs/architecture.md#notifications)). **ScheduleDO** (one per instance) holds nothing but an alarm, for the earliest schedule that is due, when it starts its session (see [Schedules](https://fctl.sh/docs/architecture.md#schedules)). **SearchDO** (one per workspace, SQLite storage) holds the search index of its sessions: their titles and descriptions, people's messages and the agents' replies, with a full-text index of them (see [Search](https://fctl.sh/docs/architecture.md#search)). **Deletion.** Deleting a project deletes its rows (sessions, members, secrets, shares, its memory, its schedules and its sessions' retrospectives cascade) and, in the same batch, queues its sessions in `session_purges` and the project in `file_purges`; their Durable Object storage, the event logs and the attached files and published screenshots, is deleted right after, and each session's documents in the search index before its event log. An hourly cron trigger (`src/housekeeping.rs`; the standalone server runs it outside Cloudflare) retries what failed and deletes expired tokens, password resets, sign-ins waiting for a code, passkey challenges, device codes and invites, early access requests whose address has an account or that are a year old, the links of expired published screenshots, abandoned Claude logins, and audit log entries older than a year. It also looks up pull requests that GitHub hasn't told about for a while ([Pull request state and checks](https://fctl.sh/docs/architecture.md#pull-request-state-and-checks)). Deleting a workspace does the same for all its projects. Deleting an account (`src/api/account.rs`) does it for the workspaces only that account is in, passes the primary admin role of its other workspaces to their longest-standing admin, or member, deletes its personal rows (its addresses too) in the same batch, and keeps a nameless placeholder row only while sessions or projects it created remain. ## Sessions A session is one Claude conversation in one sandbox, on a branch of the project's repository (and the same branch in the project's other repositories it changes; see [Repositories](https://fctl.sh/docs/architecture.md#repositories)). ``` queued → provisioning → preparing → running ⇄ idle → stopped ▲ │ │ │ │ └─ paused (usage limit) ─┘ failed (any) └──────────── a message or a resume ──────────────────┘ ``` 1. `POST /api/v1/projects/{id}/sessions` creates the row and asks FleetDO to schedule it, on a runner of its workspace that runs or builds its project's image, gives its sandbox the size it asks for and has the free disk its host keeps. Without a free one it stays `queued`, and says why. 2. FleetDO builds the `SessionSpec` just in time (repositories, branch, agent settings, secrets, fresh GitHub installation tokens, the starter's Claude credential) and sends `start_session` to a runner. 3. The host creates a sandbox with its driver (building the project's image first when it builds one from its repository and the files changed), injects `fctl-agent`, starts it detached, attaches to it, and opens the session uplink to SessionDO. 4. fctl-agent clones the primary repository, creates the branch, runs the project's setup script, checks that Claude Code is 2.1.101 or later, writes Claude's configuration over the image's, 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, and a message resumes the session (`claude --resume`; see [Idle sessions](https://fctl.sh/docs/architecture.md#idle-sessions)). ### Idle sessions A session holds a slot on its runner from the moment it starts until its sandbox stops. One that waits for a message stops after its project's idle timeout (`idle_timeout_minutes`: ten minutes unless the project sets another), so that its runner takes other work. A review agent's session waits for nobody: it stops as soon as its review is posted and its turn has ended ([Review agents](https://fctl.sh/docs/architecture.md#review-agents)). fctl-agent ends Claude, and the host stops the sandbox and keeps it (see [Sandboxes and drivers](https://fctl.sh/docs/architecture.md#sandboxes-and-drivers)). Background work keeps a session up: a shell command Claude runs with `run_in_background`, a subagent, a Monitor ([ADR 0018](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0018-background-work-keeps-a-session-up-for-an-hour.md)). Claude Code reports each task starting (`system/task_started`) and ending (`system/task_notification`), and fctl-agent follows them (`crates/fctl-agent/src/tasks.rs`). While any runs after a turn, the session waits for it ("Waiting for 2 background tasks"). When background work ends, Claude Code hands Claude the result in a turn of its own. fctl-agent counts it as any other: the session runs, and a stop or an interrupt reaches the turn. It tells these turns by their first message: `system/init`, which starts each turn, or the conversation's first message after a `result`. Claude Code also reports its state (`system/session_state_changed`, which fctl-agent turns on with `CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS`), but not each turn: from a turn that starts subagents in the background, it says `running` until they have all reported and the last turn that takes their reports has ended, and only then `idle`. So the idle timeout counts from when the last task has ended and Claude Code has said `idle`. Background work keeps a session up for at most an hour after its last turn, or for the idle timeout when that is longer, so that a forgotten dev server doesn't hold the runner's slot for ever. When a session stops with background tasks still running (that limit, a person's stop, a drain, an uninstall, a posted review), the transcript names them, and the next message tells Claude they didn't finish. The system prompt says how long background work may run after a turn ends. Such a session holds its slot as any other does, and a draining runner stops it after a minute, as it stops sessions that wait for a message. A message to a stopped session resumes it: from the web app, `fctl send`, `fctl attach` or the API, for anyone who may message the agent. SessionDO queues the session again and keeps the message in its outbox for the new run, which runs on the Claude subscription the session ran on, as a message to an idle session does. That subscription's owner must still be an active member of the session's workspace, as a runner's owner must, and still able to run the session: to start sessions in its project, or to message it through a share (`spec::credential_serves`). Once they left the workspace, were removed or disabled, lost their role in the project or the share, or the workspace's two-factor requirement closed it to them, the session's runs and token refreshes, GitHub's as well as Claude's, get no credential, and it fails with a reason instead; leaving, being disabled, or losing that role stops the sessions that run on their subscription (FleetDO, on `/workspaces-changed`). A resume (**Resume** for the session's owners, `fctl resume`) runs it on the resumer's subscription instead, and also starts a failed session, which a message doesn't. A message that comes while the session stops queues it too: FleetDO starts the new run once the last one has ended, so that two runs of a session never overlap, and SessionDO ignores what the last run's uplink still says about stopping. Messages for a session that stops wait in the outbox for the next run instead of going to the agent, and an interrupt for one is dropped, since there is nothing left to interrupt. FleetDO marks a session stopping before it asks its host to stop it. When an agent stops on its own (its idle timeout) just as a message comes, before SessionDO knows it is stopping, the agent leaves the message, and the session resumes for it once it has stopped. FleetDO remembers which runner last took each session (`sandboxes`, in its storage) and starts a resume there, where the sandbox has Claude's conversation and whatever the agent left uncommitted. While that runner runs as many sessions as it may, the session waits for it, for up to ten minutes. Then, or right away when that runner is offline or no longer gives the sandbox the session asks for, the session starts on another runner in a new sandbox: the agent clones the session's branch and starts a new conversation from the session's task. ### Draining a runner A runner that drains takes no new sessions, and the sessions it has carry on there, so that it can be taken out of rotation (for maintenance) without dropping anything. A host whose disk runs low takes no new sessions by itself, without a drain ([Sandboxes and drivers](https://fctl.sh/docs/architecture.md#sandboxes-and-drivers)). Its owner and instance admins drain one from the runners list or with `fctl admin drain`; its host drains itself with `fctl host drain`, using the runner token (`POST /api/v1/runner/drain`). Both set the same state, kept in FleetDO's storage with who started the drain and when, and either can end it. - FleetDO places new sessions on runners that don't drain; a session that waits because every runner online drains says so. - A resume still goes back to the draining runner that has the session's sandbox ([Idle sessions](https://fctl.sh/docs/architecture.md#idle-sessions)): it keeps Claude's conversation and needs no new sandbox. - Sessions that wait for a message stop once they have waited a minute, instead of after the idle timeout. FleetDO asks each idle session it sees in the runner's heartbeats, and SessionDO agrees only when no message is on its way to the agent, and marks the session stopping in the same step, so that a message that comes later waits for the next run. A message resumes the session on the same runner. So a draining runner is empty whenever no turn runs. The runners list, `fctl admin runners` and `fctl host status` (`GET /api/v1/runner`, with the runner token) show who started the drain, when, and the sessions that still run. Removing a machine is the step that moves its sessions elsewhere. `fctl host uninstall` asks the control plane which sessions run on the host; with any, it drains the runner and waits for them (`--wait`) or stops them (`--force`, or yes when it asks). Stopping drains the runner with `stop_sessions`: FleetDO marks each session stopping and asks the host to stop it (`StopReason::Shutdown`), so its agent pushes its work, and it forgets the runner as the home of every session whose sandbox it has, so a message resumes them on other runners. When the host isn't connected, FleetDO stops its sessions at once and the CLI stops the sandboxes on the machine with the driver. ### A runner that goes away A host that stops answering (a laptop gone to sleep, a host that hangs or crashed, a network that dropped) leaves its agents at work in their sandboxes, journaling what they do, and the session page says since when the runner hasn't been heard from ([Viewers](https://fctl.sh/docs/architecture.md#viewers)). Once its control connection has been closed for ten minutes, FleetDO marks its sessions stopped, so that a message resumes them on another runner, and remembers which ones it stopped this way (`stopped_away`, in its storage). When the runner comes back and its hello reports one of them still running, and no other run of it has started since, the session carries on there. FleetDO assigns it to the runner again and gives it back its runner, so that the host's uplink gets in and replays the agent's journal, and the transcript says the runner is back. That includes a session resumed meanwhile that no runner took: the agent that still runs gets the messages. A session that runs on another runner by then, that someone stopped or archived, or whose project was deleted, stops instead, with a reason that says which (`fctl_domain::reconcile`). ### Host updates Hosts update fctl by themselves, and with it the agents they put in sandboxes and their default sandbox image, to the version the control plane runs ([ADR 0053](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0053-hosts-update-themselves-to-the-control-planes-version.md)). On app.fctl.sh that is the latest release; a control plane of your own keeps its hosts at its version, which they must not run ahead of ([Protocols](https://fctl.sh/docs/architecture.md#protocols)). 1. **The version.** FleetDO's welcome says the control plane's (`FleetToHost::Welcome.version`) each time a host connects, which hosts do again after every deploy. A host that runs an older one follows it (`crates/fctl-host/src/update.rs`). It never goes back: a control plane that goes back to an older version leaves its hosts where they are, and one that says none (older ones) leaves them alone. 2. **The release.** The host downloads that version's archive for its machine from `download_base` (fctl.sh/releases unless `host.toml` says otherwise), never from where the control plane says: the control plane names only the version, three numbers. It checks the archive against its `.sha256` and runs the new fctl where it goes, which must say that version, before anything changes, with the code `fctl self update` uses. Hosts hear of a release together, so each waits up to half a minute before its first try. 3. **Until it can be downloaded.** CI deploys the release commit while the Release workflow still builds the archives, so app.fctl.sh can run a release minutes before fctl.sh has it. While the release is missing, or the server or the network fails, the host tries again every minute for an hour, then every half hour, for a control plane that runs a version its download server doesn't have. A download that is no use (a checksum that doesn't match, a new fctl that doesn't run on the machine) waits ten minutes, then twice as long each time, up to 12 hours (`fctl_domain::updates::retry_in`), and a newer version starts over. 4. **The restart.** The host installs the release, fctl and the Linux agent next to it, each written beside its place and then renamed over it, once no sandbox is being made, stopped or removed there, no session waits to start, no package caches are being measured or emptied and no Claude sign-in runs; until then it looks again every five seconds. Then it restarts on it: as the host service's process (launchd's or systemd's, which `launchctl print` or `systemctl show` say) it exits, and the service starts the new fctl; run otherwise, it runs the new fctl in its own place (`exec`). Sessions carry on in their sandboxes, as over any restart, and the host's uplinks reconnect within seconds. `auto_update = false` in `host.toml` turns this off (`fctl host auto-update off`, which changes that line alone and restarts the service, or `fctl host install --auto-update off`); `host.toml` files from before it have it on. Where Homebrew installed fctl, Homebrew updates it (`brew upgrade fctl`), and a host that can't write to fctl's directory doesn't update itself either. Hosts say in their hello whether they update themselves (`HostHello.auto_update`, shown as the runner's `auto_update`), and their log says why not when they don't. `fctl host status`, `fctl host doctor` and `fctl host auto-update` say it too. ### Repositories A project has a primary repository and may list more of the same GitHub account (`project_repos`), each read only or read and write, or grant read access to every repository of the account the GitHub App reaches (`owner/*`), all within what its workspace may use ([GitHub accounts](https://fctl.sh/docs/architecture.md#github-accounts)): an owner-wide grant reads only the workspace's repositories of the account. One installation mints a session's tokens: one that can write (the primary repository and the writable ones), as far as the session's agent may on GitHub ([GitHub access](https://fctl.sh/docs/architecture.md#github-access)), and one that can only read. A token carries one set of permissions, hence two; they are refreshed together (`CredentialKind::Github`). A listed repository the workspace may no longer use, or the installation no longer reaches, is left out of them and marked unavailable in the spec, and the agent posts a notice; a primary repository out of reach fails the session. - **Credentials.** git asks fctl-agent's credential helper, which reads the repository path git sends (`credential.useHttpPath`): writable repositories get the token that can write, others the read-only one. `gh` holds one token per host, the one that can write. - **Clones.** The primary repository is cloned into `~/workspace` before Claude starts. The system prompt lists the others, and Claude clones them when it needs them, into `~/repos/<owner>/<name>`. - **After each turn** fctl-agent finds those clones by their `origin`. In a writable one, work on the default branch moves to the session branch (which Claude is told to use), new commits on it are pushed, and its pull request is looked up. The `git` event carries the primary repository in its fields and the others in `repos`. - **Changes** are what each branch changed since it left its base: the diff from the merge base of the branch and `origin/<base>`, uncommitted and untracked work included, so the base's later commits never show as the session's. Once the branch's pull request merged, from the base as it was just before (the pull request's `baseRefOid`), so that the work it merged still shows, however it merged. - **Pull requests.** fctl-agent follows every pull request of its session (`crates/fctl-agent/src/followed.rs`): those of the branches it looks up, those whose URL `gh pr create` printed, whichever branch, worktree or clone they came from, and those the session lists when a sandbox starts (the spec's `pull_requests`). It keeps them in its state file, reports them all in the `git` event's `pull_requests`, and looks each up again when a turn ends until it is merged, so the session lists them with their state (`fctl_domain::pulls`), and GitHub keeps that current ([Pull request state and checks](https://fctl.sh/docs/architecture.md#pull-request-state-and-checks)). The system prompt tells agents to make another pull request's branch where the repository is checked out, not in a worktree, since Changes shows the checked-out branches. - **Merged branches** aren't pushed again: GitHub deletes a merged pull request's branch in repositories set up to, and a push would bring it back. Before it pushes a branch whose pull request it saw open, or whose upstream a fetch pruned (`[gone]`), fctl-agent looks the pull request up. Commits after a merged one stay in the sandbox: the transcript says so, and Changes shows the branch merged, with the commits since (`unmerged`) not pushed ([ADR 0030](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0030-sessions-follow-every-pull-request-their-agent-opens.md)). - **While Claude works** fctl-agent reports the same state soon after tool calls that can change the repositories (`Bash`, `Write`, `Edit`, `NotebookEdit`, a subagent's too) and when background tasks end: a burst costs one refresh, refreshes come at most every ten seconds (`crates/fctl-agent/src/refresh.rs`), and a `git` event goes out only when the state changed. These refreshes only read: they push nothing, move no work, and look up a branch's pull request only once it was pushed or its pushed head moved, or after `gh pr create`, along with the pull requests it printed. They read a copy of each clone's index, since `git diff` rewrites the index it reads under its lock, which would fail the agent's own `git commit` ([ADR 0027](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0027-agents-report-their-repositories-while-claude-works.md)). The session page's Changes section and the session lists take the newest state; the transcript shows one when a branch gets a commit, a push or a new pull request (`fctl_domain::branches`). Older hosts and agents ignore the list and the second token, and work on the primary repository alone ([ADR 0009](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0009-projects-span-an-accounts-repositories.md)). ### GitHub access A session's agent pushes, comments or only reads on GitHub (`GithubAccess`, `sessions.github_access`) in the repositories the session may write to; it only reads the others ([ADR 0037](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0037-access-tokens-and-sessions-limit-what-agents-do-on-github.md)): | Access | Its token for those repositories | The agent | |---|---|---| | `push`, as every session's before | contents, pull requests, issues and workflows: write | commits, pushes its branch and opens pull requests | | `comment` | pull requests and issues: write; contents: read | reviews, comments, labels and resolves review threads; pushes and merges nothing | | `read` | none: the read-only token covers them too | reads; writes nothing | Issues are written or read only where the project's ticket tracker lets agents ([Ticket trackers](https://fctl.sh/docs/architecture.md#ticket-trackers)). A session asks for one as it starts (`CreateSessionRequest.github_access`, `fctl run --github`, the list beside the composer's model and effort), at most what its project's settings allow (`ProjectSettings.github_access`, `fctl projects set --github`) and the access token that starts it, and gets as much as they allow when it doesn't ask (`limits::session_github`). Its tokens carry what it may do now: what it started with, within its project's setting as that is now (`limits::effective`), so that lowering a project's setting reaches its running sessions with their next token, within the hour, as removing a repository does. A value this version doesn't know, stored by a newer one, reads as `read`. GitHub holds the agent to it, since git and `gh` have no token that can do more. fctl-agent reads it in the spec (`SessionSpec.github_access`). For an agent that doesn't push, it pushes nothing, moves no work onto session branches and treats the other repositories as read only, and it tells Claude what the agent may do instead of how to push and open pull requests; the transcript says so as the session starts. Hosts from before it leave it out of the agent's configuration: their agents try to push, and GitHub refuses. GitHub lets only tokens that can write a repository's contents resolve the review threads of its pull requests, so an agent that comments resolves them through fctl: `fctl resolve PRRT_…` in the sandbox goes to the control plane as an agent call (`AgentCall::ResolveThread`), as `fctl memory` does. The control plane looks the thread up through the project's installation, checks that its pull request is in a repository the session may write to and its workspace may use (and refuses one elsewhere as it refuses one that doesn't exist, naming no repository), and resolves it, or opens it again, with a token of its own for that repository, which never leaves the control plane and which it revokes at once (`fctl_domain::threads`, `repos::resolve_permissions`). `~/AGENTS.md` tells agents that comment how, where the sandbox's fctl has the command. ### Pull request state and checks Session lists, the session page and `fctl show` give each pull request its state, `open`, `draft`, `queued` (in a merge queue), `merged` or `closed`, since when, and the checks of its head commit: how many passed, failed and are still to finish. Agents look their pull requests up as turns end, but a session that stopped looks up nothing, so GitHub tells the control plane ([ADR 0032](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0032-githubs-webhook-keeps-sessions-pull-requests-current.md)): 1. **The webhook.** Where the GitHub App's webhook is on, with its secret in `GITHUB_WEBHOOK_SECRET` ([Self-hosting](https://fctl.sh/docs/self-hosting.md#connect-github-optional-recommended)), GitHub posts to `/api/v1/github/webhook` when a pull request changes (`pull_request`: opened, pushed to, marked ready or a draft, queued, merged, closed…) and when the checks of a commit do (`check_run`, `check_suite`, `status`). A delivery counts only with the signature of that secret (`fctl_domain::webhooks::signed`); without the secret the instance takes none and sessions show what their agents last looked up. 2. **Which sessions.** D1 keeps which sessions list which pull request (`pull_request_sessions`), by repository and number, with its head commit, which commit statuses name instead of a pull request. SessionDO adds a session's rows as its agent finds pull requests. 3. **A lookup.** For the pull requests sessions list that a delivery concerns, the control plane asks GitHub with one GraphQL query (`fctl_domain::webhooks::query`, with a token of the installation that sent it): for each, its title, state, merge queue entry, times and its head commit's checks counted by state, each check once: GitHub counts every run of a check, but the pull request shows its latest run alone, so a run that a re-run, or the next run of its workflow for the same commit, replaced counts no more. Deliveries come out of order and some never come; a lookup says how things are. A pull request is queued when the delivery's action is `enqueued` (or `queued`), GitHub lists its entry in the merge queue, or its merge state status (else its `mergeable_state`) is `queued` or `in_merge_queue`. When the lookup fails, a `pull_request` delivery's own payload gives the state. 4. **The sessions.** The control plane sends what it found to the SessionDO of each session that lists the pull request (`/pull-requests`), which takes what is newer (`fctl_domain::pulls::update`): GitHub's `updatedAt` orders what GitHub and agents say of its title and state, an agent's `open` leaves a queued one queued (`gh` can't see merge queues), and checks go by when the control plane asked. It keeps when the state changed (`state_changed_at`): when GitHub says it merged, closed or joined the queue, or else when fctl saw it change. Viewers and the dashboards hear of it at once, and D1 within seconds. The control plane also looks up the pull requests a session finds as it finds them, and the hourly job up to 500 that aren't merged or closed and that nobody asked GitHub about for six hours (`pull_requests::sweep`), in case a delivery went missing: that also brings the pull requests of sessions from before the webhook up to date, all in its first run. One query asks about up to 50 pull requests of an account, for one point of the rate limit of the app's installation there (5,000 an hour), which the agents of its sessions share ([ADR 0033](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0033-the-control-plane-looks-pull-requests-up-50-at-a-time.md)). GitHub gives up on a query that takes more than ten seconds, as one of 50 pull requests with a hundred checks each does. The job then asks about them again 10 at a time, and one at a time if GitHub gives up on 10, and keeps to that for the rest of the account's ([ADR 0042](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0042-lookups-github-gives-up-on-ask-about-fewer-pull-requests.md)). It leaves an account's other pull requests to its next run when GitHub doesn't answer, gives up on one alone, or the installation has fewer than 1,000 points left, and all that are left after five minutes of lookups. Each session hears of all of its pull requests at once, and `pull_request_sessions` keeps a pull request's old state until every session that lists it heard, so that the job asks again for one that didn't. The web app shows the states and checks with GitHub's octicons and colors (the design system's `pr-*` and `checks-*` tokens). ### Review agents Where a project turns them on, an agent reviews each new commit of the pull requests in the repositories its agents may write to (its primary repository and those it lists as read and write), drafts too, and fctl posts what it found on GitHub ([ADR 0040](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0040-review-agents-review-pull-requests-on-an-owners-subscription.md)): 1. **Settings** (`project_reviews`, the project's **Reviews** tab, `fctl review settings`, `GET`/`PATCH /api/v1/projects/{id}/review-agents`). The project's owners, its workspace's admins among them, turn them on, and they run on the Claude subscription of whoever did: their operator, whose connection for the workspace each review session runs on. Another owner takes them over (`run_as_me`), and nobody puts them on someone else's subscription; the operator may always turn them off. Owners also choose their model and effort level (else the project's), instructions that follow fctl's own, and the GitHub Apps whose pull requests start reviews by themselves (`bots`, below). The settings say why no review starts, where none would (`problem`): no GitHub App or webhook, no repository to review, or an operator who left, can no longer start sessions in the project, or has no Claude connection. 2. **What starts one.** A `pull_request` delivery of GitHub's webhook (`opened`, `reopened`, `synchronize`, `ready_for_review`) about an open pull request starts a review of its head in each project whose review agents are on and that may write to its repository, through the installation its workspace connected (`crate::reviews::on_delivery`, `fctl_domain::reviews::starts_review`), when its author may write to the repository or it is fctl's own app (`trusted`, below), or one of the apps the project's owners chose opened it from a branch of the repository (`starts_by_itself`, `by_bot`): a review runs on the operator's subscription, with their Claude token in its sandbox, and Claude acts on what the pull request says, so a pull request from anyone else starts nothing by itself ([ADR 0047](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0047-review-agents-review-outsiders-pull-requests-when-asked.md)). The apps are a project's list of GitHub Apps' bots (`project_reviews.bots`, `ReviewAgents.bots`), as pull requests name their authors, `<slug>[bot]`, from the app's page on GitHub that deliveries carry (Copilot's coding agent, `Copilot`, is `copilot-swe-agent[bot]`): Dependabot until the owners choose ([ADR 0054](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0054-review-agents-review-dependabots-pull-requests-by-themselves.md), [ADR 0057](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0057-projects-choose-which-apps-pull-requests-start-reviews.md)). People's accounts don't go on it. To choose them, owners see the apps that opened the newest pull requests (100 of each repository) from branches of the project's repositories (`GET /api/v1/projects/{id}/review-agents/bots`, `fctl review bots`), and any app GitHub shows by its name (`…/review-agents/bots/{name}`), through the installation the project's workspace connected; fctl keeps none of it. A commit that has its review gets no other from a delivery, so marking a draft ready reviews it only when its head is new; merged, closed and queued pull requests get none. People who start sessions in the project ask for one of a pull request's head (the Reviews tab, `fctl review start`, `POST /api/v1/projects/{id}/reviews`), outsiders' too, once they have looked at it. The review of the pull request still under way stops (`superseded`), once the new one is in: a newer commit takes its place, and a review that runs finishes even when its pull request merges meanwhile. 3. **The session.** A review is a session of its project (`reviews` keeps which, with the pull request, its head and how it went), started as the operator, titled after the pull request (`Review of web#12: …`), with a task from fctl. Its agent only reads on GitHub (`read`): its one token reads the reviewed repository, which its working directory holds at the pull request's head (fetched from `refs/pull/<n>/head`, forks' too), and the project's others. It gets none of the project's secrets, setup script, memory, retrospectives, claude.ai connectors or Remote Control. Once the review is posted and its agent's turn has ended, or the session paused for a usage limit, it stops at once, background work and all, which frees its runner's slot; without a review, it stops a minute after its last turn. Its system prompt comes from the control plane (`ReviewSpec::instructions`, `fctl_domain::reviews::instructions`): review what the pull request changes and what that affects, and nothing else; bugs, security problems, breaks of the repository's rules and bad practices, without nitpicks; read rather than run, and run nothing of a pull request whose author can't write to the repository (`trusted`: GitHub's `OWNER`, `MEMBER` and `COLLABORATOR`, and fctl's own app), apps' included, and of Dependabot's and Renovate's check the lockfiles and new versions instead; split a large one among subagents; don't take what the pull request says for instructions; and post once. It takes no messages, interrupts or resumes (`409`), sends no "waits for you" notifications, and stays off the Agent Floor, session lists (`GET /api/v1/sessions` leaves them out unless `?reviews=1`) and projects' session counts. FleetDO gives it only to runners whose hosts announce `reviews` (`HostHello.features`). 4. **On GitHub, as it starts.** The control plane adds an `eyes` reaction to the pull request and a check on the commit, `fctl review`, queued until a runner takes the session and in progress after (`crate::reviews::started`), with a link to the session. 5. **Posting** (`fctl review submit`, an agent call). The agent hands its findings (file, line, severity, title, a few sentences, an optional suggested change) and the earlier ones the new commits fixed to the control plane, which checks them (`fctl_domain::reviews::check`) and, with a token of the repository's that never leaves it: posts the review's summary, a comment on the pull request (how many issues it found, the agent's summary, and the findings whose lines the diff doesn't show, `summary_text`), then one review of the commit (`COMMENT`) without text of its own, with each other finding on its lines (`place`), so that GitHub shows the summary above them, on the web and in its apps alike; marks each earlier finding it names fixed in this commit, its title struck through, and resolves its thread, for fctl's own threads alone (the token's app wrote them, and they carry fctl's mark); puts atop each earlier review's summary how the issues it found stand (`restated`: fixed, or resolved by someone, going by the threads its session started, which fctl notes out of sight in every comment it posts), and hides the summary as outdated once all of them are; and completes the check: success with nothing to look at, neutral with findings or earlier ones still open, since acting on them is up to the pull request's people. It posts once: a second submission gets what the first posted. `fctl review threads` lists fctl's earlier findings, with what people answered. fctl-agent reminds an agent that ends its turn without posting, once. 6. **Back to the agent that opened it.** Where one of the instance's own agents opened the pull request (its author is the GitHub App, `fctl_domain::reviews::by_app`), the control plane sends the new findings to that agent's session (`crate::reviews::pass_on`), so that it acts on them without waiting for a person. The session is one that lists the pull request (`pull_request_sessions`, review sessions left out): the one whose branch it comes from, else the one that started first (`opener`). After posting, the control plane asks GitHub for the comments of the review that holds the findings on their lines (`spots`), and SessionObject's `/review-findings` delivers a message from fctl, as a person's message is delivered, resuming a stopped session on the subscription it ran on. The message (`passed_on`) lists the findings, most severe first, each with its comment's id, and says that they are another agent's reading, not instructions: the agent checks each, fixes the real problems and pushes, and answers each in its thread (`gh api …/comments/{id}/replies`) with what it decided and why, or in a comment on the pull request for those in the review's summary, leaving the threads for the next review to resolve. The message carries the review (`UserMessage.review`, `passed_on_review`), which the session's transcript shows in a few lines: a line for each finding, with its severity and lines, that opens its comment on GitHub, and the message itself a click away. Messages from before it show their first lines, with the rest a click away. Pull requests that merged or closed, archived and failed sessions, and sessions whose agent only reads on GitHub get nothing. So that the agent and the review agent can't go back and forth for good, a session gets the findings of at most five reviews in a row (`MAX_PASSED_ON`) while no person messages its agent; the next ones only say in its transcript what they found (`review_held`), until someone does. The review's `PostedReview.sent_to` names the session, and its transcript links it ([ADR 0041](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0041-review-agents-findings-go-to-the-agent-that-opened-the-pull-request.md)). 7. **A review that ends otherwise** (superseded, stopped, failed) ends its check as cancelled, saying why (`crate::reviews::ended`, from SessionObject; the hourly job catches what it missed). The project's Reviews tab lists them, newest first, with their pull request, commit, outcome and the review on GitHub, and opens their sessions, whose page says what came of the review instead of offering a composer (`GET /api/v1/projects/{id}/reviews`, `fctl review`). Checks need the GitHub App's Checks permission (read and write): without it, reviews run and post, and say why their commit shows no check ([Self-hosting](https://fctl.sh/docs/self-hosting.md#connect-github-optional-recommended)). ### Follow-ups Where a project has them on (`ProjectSettings::follow_ups`, the project's Settings, `fctl projects set --follow-ups`), what happens on GitHub to a pull request one of the instance's own agents opened (its author is the GitHub App) brings that agent back to it, in a message from fctl delivered as a person's message is: it resumes a stopped session on the subscription it ran on ([ADR 0043](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0043-agents-come-back-to-their-pull-requests-for-comments-failed-checks-and-merges.md), [ADR 0045](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0045-merge-conflicts-bring-agents-back-too.md)). Four kinds, each on or off on its own. New projects start with all four on, except those their create request turns off (`fctl_domain::settings::for_new_project`, [ADR 0051](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0051-new-projects-start-with-follow-ups-on.md)), and store each one; a project stored without one, from before it existed, has it off: 1. **Comments that address the agent** (`issue_comment`, `pull_request_review` and `pull_request_review_comment` deliveries, `crate::follow_ups::on_comment`): a new comment on the pull request's conversation, a review, or a comment on its lines, that mentions `@fctl` or `@agent`, or starts with `fctl:`, `agent:`, `fctl,` or `agent,`, in any case (`fctl_domain::follow_ups::addressed`; what it quotes, shows as code or hides in an HTML comment doesn't count, nor do edits). Only a person's (GitHub's `User`, not an app's bot, so not fctl's own review agents, whose findings come back on their own), whose `author_association` is `OWNER`, `MEMBER` or `COLLABORATOR`, and whom GitHub then says may write to the repository (`GET /repos/{owner}/{name}/collaborators/{login}/permission`: admin, maintain or write), so that nobody else steers the agent through a public repository, nor a member of its organization who may only read it. A comment on the conversation names neither branch: where several sessions list its pull request, or it merged, the control plane looks it up (`fctl_domain::reviews::lookup`). A review brings its comments on lines (`GET …/reviews/{id}/comments`), which also come as deliveries of their own. Once the session has a comment, fctl adds an `eyes` reaction to it with a token for the repository alone, which it revokes at once, and the workspace's audit log records it (`follow_up.comment`). 2. **Failed checks** (`check_run`, `check_suite` and `status` deliveries): the lookup after one (`crate::pull_requests::hook`) names the checks of the pull request's newest commit that failed (`fctl_domain::webhooks::failing`: check runs whose latest run failed, timed out or couldn't start, and commit statuses that failed or errored; a cancelled run, or one waiting for approval, is no failure of the code), for pull requests neither merged nor closed, drafts too. The lookup asks for the author as REST names it: GraphQL calls the app's bot `fctl` where REST says `fctl[bot]`. 3. **Merged or closed** (`pull_request` deliveries of `closed`): who merged or closed it, an app's bot (a merge queue's) counting as nobody, and the branch it went into. 4. **Conflicts with the base branch** (GitHub's `mergeable` is `CONFLICTING`, `fctl_domain::follow_ups::conflicts`), for pull requests neither merged nor closed, drafts too. No delivery says when a pull request starts to conflict, and GitHub works it out only when someone asks after either branch moved, in the background: until then it says `UNKNOWN` (`fctl_domain::webhooks::Mergeable`). So the lookups ask: the one after any delivery about the pull request; the one when a session finds it, since a pull request opened with conflicts gets no check deliveries (GitHub runs none of its `pull_request` workflows); and after a delivery that merged one into a repository, a lookup without checks (`webhooks::Fields::WithoutChecks`, a point of the rate limit for up to 50) of the other pull requests that sessions list there, neither merged nor closed, whose projects follow up on conflicts (`crate::pull_requests::moved_onto`). While GitHub doesn't know yet, the session that opened it asks again (SessionObject's `asks`, `crate::pull_requests::ask_again`), 15 seconds later, then 30, 60, 120 and 240 (`ASK_AGAIN_MS`), and after that leaves it to the next delivery. A push straight to the base branch (a release's commit) shows at the next merge into the repository, or the next delivery about the pull request. The session is the one that opened the pull request (`fctl_domain::reviews::opener`), among those it may be looked up for ([Pull request state and checks](https://fctl.sh/docs/architecture.md#pull-request-state-and-checks)), and nothing reaches it when it is archived or failed, its project is archived, or its agent only reads on GitHub. SessionObject takes them at `/follow-up`, and with a lookup's `/pull-requests`, and keeps them for ten seconds (`follow_ups`, in its storage, `GATHER_MS`), so that a review and its comments, or checks that fail together, reach the agent in one message, and what reached it in the last day (a comment by its id, failed checks and conflicts by their commit, a merge) doesn't come again: once the agent pushed what resolves conflicts, they come again only at its next commit. Then, for each pull request, a message from fctl (`fctl_domain::follow_ups::message`) says what happened: who wrote what and where, quoted, a review's comments on lines under it, and how to answer each where it was made (`gh pr comment`, `gh api …/comments/{id}/replies`); which checks failed and how to look into them (`gh pr checks`, `gh run view --log-failed`), waiting for those still running before a push; for conflicts, to merge the updated base branch in, resolve them so that both sides' changes still do what they were for, run the tests and push, without rebasing or force-pushing, and to ask on the pull request when a conflict takes a decision the code doesn't answer; after a merge, what may be left to do. It says that only people who can write to the repository reach the agent this way. Failed checks and conflicts wait while a turn runs, since the agent may be at them, and ten seconds more once it ends, for what it pushed then, and go only while GitHub still says that they are the pull request's newest commit's: a newer commit, the agent's or anyone's, or for checks a re-run that passed, makes them old news. After three failed checks in a row (`MAX_CHECKS_IN_A_ROW`) while nobody messaged the agent, nor wrote to it on GitHub, the transcript says what failed instead (`follow_up_held`, with `Notice.follow_up`), until someone does; conflicts count their own three (`MAX_CONFLICTS_IN_A_ROW`), so that a pull request nobody merges doesn't take in its base branch after every merge for good. Only deliveries, and a session that finds its pull request, bring agents back: the hourly sweep keeps pull requests' state current without waking anyone. The message carries what it is about (`UserMessage.follow_up`), which the transcript shows as GitHub has it: people's comments with their avatars, a review's comments on lines under it, failed checks by name, conflicts with the base branch, a merge, with what fctl told the agent a click away. A session's page says under its pull requests what brings its agent back. Follow-ups need the webhook, and for comments the app subscribed to **Issue comment**, **Pull request review** and **Pull request review comment** ([Self-hosting](https://fctl.sh/docs/self-hosting.md#follow-ups)). ### Schedules A project's schedules start sessions on their own: once at a set time, again and again on a cron expression in a time zone, or only when someone, or another tool, runs one ([ADR 0044](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0044-schedules-start-sessions-on-their-owners-subscription.md)): 1. **What a schedule is** (`schedules`, the project's **Schedules** tab, `fctl schedules`, `GET`/`POST /api/v1/projects/{id}/schedules`, `GET`/`PATCH`/`DELETE /api/v1/schedules/{id}`): a name, the task its runs' agents get, when it runs, and the model, effort level, base branch and GitHub access its sessions start with, else the project's, and never more on GitHub than the project allows now (`limits::effective`). People who start sessions in the project set them up, on their own Claude subscription: the schedule's owner. The owner, and the project's maintainers and owners, change it; a change by anyone else, a resume or **Run it on my subscription** runs it on theirs from then on (`fctl_domain::schedules::rights`), so that nobody puts work on someone else's subscription. The owner, whatever their role now, and the project's maintainers and owners pause and delete it. Limited access tokens read schedules, and run them with `run`; setting one up or changing it takes `full`, since its sessions start after the token is gone (`limits::allows`). A token's cap on GitHub stays with what it sets up, changes, resumes or takes over (`fctl_domain::schedules::github_within`). A project keeps at most 50. 2. **When.** Cron expressions have Vixie cron's five fields (`fctl_domain::cron`) and run at most every 15 minutes; their times are wall-clock times in the schedule's IANA time zone. The control plane reads zones with JavaScript's `Intl` (`schedules::IntlZone`), as the web app's browsers do, so neither carries a zone database of its own: a time the clock skips, as it moves to summer time, runs as far past the change as it was meant to be, and one it goes through twice runs once, the first time. D1 keeps each schedule's next run (`next_run_at`). 3. **The alarm.** ScheduleDO keeps nothing but its alarm, which it sets for the earliest next run when schedules change (`/wake`, from the API, and from the hourly job in case one went missing) and after each time it rings. Alarms ring wherever the Worker runs: on Cloudflare, in the standalone server and under `wrangler dev`, which runs no cron triggers by itself. It takes up the schedules that are due, 20 at a time and the longest due first, and claims each in D1 by moving it to its next time (`next_run_at`, compared and set in one statement), so that a retried alarm starts nothing twice; one whose runs were missed, while the instance was down, runs once. 4. **A run at its time** (`crate::schedules::take_up`) starts a session as the owner, on their Claude connection for the workspace, named after the schedule, which the session records (`sessions.schedule_id`, and `Session.schedule`, with the schedule's name while it exists): session lists mark it, and its page links to the schedule. Its first message is the task, then a note from fctl (`fctl_domain::schedules::prompt`): which schedule started it, when and for whom, its previous run, and that nobody watches it start, so that its agent works without waiting for answers and says plainly what only a person can decide. The transcript shows the note apart from the task. The owner follows the session, as starting one does. A run starts none, and says why (`schedule_runs`, the newest 100 of each schedule): - while the schedule's last session still works on its task (queued, starting, running, or paused on a usage limit): runs don't pile up; - while the owner's plan is at its limit (`claude_usage`, `schedules::at_limit`): the runs until it resets are skipped, and a schedule that runs once waits for the reset instead; - once the owner can no longer start sessions in the project, their account is gone or disabled, or they have no Claude connection, and while the project is archived: fctl pauses the schedule and keeps why (`paused_reason`), which the tab and `fctl schedules` show, with the same reasons for the next run beforehand (`Schedule.problem`). A failure after the claim is recorded as a failed run, so that a schedule that runs once never looks done without having run. 5. **A run asked for** (`POST /api/v1/schedules/{id}/runs`: **Run now**, `fctl schedules run`) starts the task now, on the caller's subscription and within their token's GitHub limit, for people who start sessions in the project and tokens that run sessions there, at most 20 times an hour, unless the schedule is paused: pausing it stops what tools ask for too. A schedule without a time runs only this way, each run on the subscription of whoever runs it. What comes with a run (`context`, at most 20,000 characters) follows the task in the first message: a person's as theirs; from an access token, which an issue tracker or another tool holds, inside `<context>`, marked as information from outside fctl and not instructions, since anyone may have written it. Each run records what started it (`trigger`: its time, a person or a token) and for whom, and the audit log each session it started. The Schedules tab lists a project's schedules with when each runs next and what its last run did, and offers ideas to start from. Its form says when one runs in words and shows its next runs as you choose, found as the control plane finds them (`web/src/lib/schedules.ts`), and the task boxes hand it the task you wrote (**Schedule**: the Sessions tab's, and the Agent Floor's from `xl`, where its row has room for it). Each schedule's details show the request another tool sends to run it. Other tools start agents this way ([Automation](https://fctl.sh/docs/automation.md#start-agents-from-other-tools)); Linear's tickets start them through fctl's own integration ([Linear](https://fctl.sh/docs/architecture.md#linear)), which starts sessions as schedules do (`crate::starts`), one for each ticket rather than one at a time. ### Ticket trackers A project chooses where its agents read and write tickets (`ProjectSettings::tickets`, the project's **Tickets** tab, `fctl projects set --tickets`, [ADR 0052](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0052-projects-choose-a-ticket-tracker-and-linear-delegates-tickets-to-fctl.md)): none, GitHub Issues, or a team in Linear. Projects from before it, and new ones, use GitHub Issues of the repositories their agents write to, as agents always did. Agents reach the one chosen alone, and fctl keeps them to it twice: what their tokens can reach, and what they are told (`fctl_domain::tickets`). | Tracker | Their GitHub tokens | How they reach it | |---|---|---| | None | no issues permission | they reach none, and are told to keep away from trackers and leave issue references out of commits and pull requests | | GitHub Issues of the repositories they write to | issues as before: written where they push or comment, read where they read | `gh issue` | | GitHub Issues of one repository, one they write to | issues only on a write token for that repository alone; none on the read-only token | `gh issue` where that repository is the only one they write to, else `fctl ticket` | | Linear, a team (and optionally one of its projects) | no issues permission | `fctl ticket` | A GitHub token carries one set of permissions for all its repositories, so the issues of one repository among several go on no session token; the control plane reaches them for the agent instead. A public repository's issues are public: GitHub serves them to any token, so there the instructions keep agents away. A repository chosen that agents no longer write to reaches nothing, and the Tickets tab says so. Running sessions get the tracker's permissions with their next token, within the hour, as with `github_access`. Review agents read issues only where the project's tracker lets agents, and never reach Linear. A maintainer can start a session that reaches GitHub's issues beyond the tracker (`CreateSessionRequest::github_issues`, `fctl run --github-issues`, [ADR 0055](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0055-maintainers-start-sessions-that-reach-github-issues-beyond-the-tracker.md)), to move them into Linear, say. Its tokens carry the issues permission as in the table's second row (`fctl_domain::tickets::session_issues`), for as long as it runs; it reaches the tracker as other sessions do, with `gh` where that is one repository's issues, which its tokens then reach; and its agent is told to use GitHub's issues only as its task asks. Agents whose tokens reach no issues are told that a maintainer can start such a session, so that a task that needs them ends with a way on. 1. **`fctl ticket`** in the sandbox goes to the control plane as an agent call (`AgentCall::Ticket`), as `fctl memory` does: a `TicketRequest` (`fctl_proto::tickets`) that hosts and fctl-agent pass on as it is, so that what `fctl ticket` does grows with the control plane and the sandbox's fctl. It lists, searches and shows tickets with their comments, parent and sub-tickets, files them (sub-tickets too), comments (replying in trackers that thread comments) and changes their state, labels, parent and priority, the same commands whatever the tracker (`crate::tickets`). A `Ticket` holds what trackers share (a key such as `ENG-123` or `acme/web#12`, a title, a description, a state of a kind: triage, backlog, unstarted, started, completed or canceled, labels, people, a parent and children) and the rest as named fields (an estimate, a cycle, a milestone), so that a tracker added later fits it. The control plane keeps each call to the project's choice: a Linear issue of another team, or a GitHub issue of another repository, is refused as one that doesn't exist. It writes in the name of fctl's own app: with the Linear workspace's token ([Linear](https://fctl.sh/docs/architecture.md#linear)), or with a token of the GitHub App's for that repository's issues alone, which never leaves it and which it revokes at once. Agents that only read on GitHub read tickets alone, in any tracker; review agents reach no tracker. In an hour, a session's agent comments at most 30 times, files at most 20 tickets, changes at most 60 and reads at most 300 times (`ticket_writes`, each call taking its place in one statement): an agent that loops would otherwise fill a team's tracker, or use up the API of a Linear installation that other workspaces share. Its parents, sub-tickets and search results outside the project's choice don't reach it either. The transcript shows what it did there (`ticket_commented`, `ticket_created`, `ticket_updated` notices, which only the control plane adds). 2. **What agents are told** comes from the control plane (`SessionSpec::tickets`, `fctl_domain::tickets::instructions`), so that it grows with the trackers it knows: which tracker, how to reach it, and for a session that started from a ticket, how its replies go back and what moves the ticket on. fctl-agent puts it in the system prompt, with a line of its own where the sandbox's fctl has no `fctl ticket`. Hosts and agents from before trackers tell agents nothing of them, while their GitHub tokens follow the tracker. ### Linear People who track their work in Linear delegate issues to fctl there ([ADR 0052](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0052-projects-choose-a-ticket-tracker-and-linear-delegates-tickets-to-fctl.md)). The instance has one Linear OAuth app (`LINEAR_CLIENT_ID`, `LINEAR_CLIENT_SECRET`, [Self-hosting](https://fctl.sh/docs/self-hosting.md#connect-linear-optional)), which workspaces install in their Linear workspaces as an agent: Linear then lists fctl as a user people assign issues to, which makes it the issue's delegate (the person stays its assignee), and mention. 1. **Installing it** (the workspace's **Linear** tab, a project's Tickets tab, `fctl workspaces linear connect`). A workspace's admin signs in to Linear from fctl, through the app's OAuth client with `actor=app` and PKCE (`api::linear`), which takes an admin of the Linear workspace too: Linear asks them to install the app and to choose the teams it sees. Linear keeps one token for an app's user, so a Linear workspace has one installation here (`linear_installations`: its access and refresh tokens sealed with the master key, bound to `linear:<organization>`), which every fctl workspace connected to it shares (`workspace_linear`): installing the app again, to connect another fctl workspace, replaces the token for all of them. Access tokens last a day; the control plane renews one that runs out within ten minutes (`crate::linear::client`), and two renewals that race end alike, as Linear answers a refresh it already answered the same for half an hour. One whose renewal Linear refuses, or that Linear says was uninstalled (an `OAuthApp` revocation), is revoked here until an admin connects it again. A workspace that disconnects a Linear workspace hears nothing more from it and reaches none of its tickets, whatever other workspaces do; once no fctl workspace works in it, fctl revokes the app's token there (the hourly job catches deleted workspaces). 2. **Choosing a team.** A project's maintainers choose a team, and optionally one of its open Linear projects, among those the app sees in a Linear workspace connected to the project's workspace (`GET /api/v1/workspaces/{id}/linear/{organization}/teams`), which the control plane checks with Linear as the choice is saved (`tickets::check_linear`). Linear prices a query by how much it asks for, whatever it finds, and refuses one of more than 10,000 points, so the control plane counts the teams first (up to 250) and then asks for their workflows and open projects 50 teams at a time, under 5,000 points a query; a test prices every query fctl sends to Linear (`fctl_domain::linear`). 3. **Knowing people.** fctl starts sessions only for people it knows in the Linear workspace, as them: on their Claude subscription, while they may start sessions in the project. People link their Linear account (Settings, a project's Tickets tab, `fctl account linear connect`, or Linear's own button in an agent session that waits for it): a Linear sign-in of theirs, with `read` alone, shows fctl who they are there (`viewer`), which D1 keeps (`linear_accounts`: their Linear id, name, email and avatar), and fctl revokes the sign-in at once. A Linear account is one person's: whoever links it last has it. Matching addresses would let anyone whose Linear address matches someone's run their work on that person's subscription, and an address in Linear says nothing an fctl account has to agree with, so fctl matches none. 4. **The webhook** (`POST /api/v1/linear/webhook`, with the signing secret in `LINEAR_WEBHOOK_SECRET`): a delivery counts only with that secret's signature (`Linear-Signature`, the HMAC-SHA256 of its body) and a `webhookTimestamp` within a minute of now (`fctl_domain::linear`). Linear wants an answer within five seconds and an activity in the agent session within ten, so the control plane answers at once and works after (`ctx.defer`). Each agent session is a thread here (`linear_threads`, by Linear's id): waiting, started (with the fctl session it reaches) or refused. 5. **A delegation or a mention** (`AgentSessionEvent` `created`, `crate::delegations::created`). fctl thinks aloud in the agent session at once ("Looking at ENG-123…"), then finds the person who made it among the people it knows; without them, it asks them in Linear to link their account (an elicitation with Linear's `auth` signal, whose link starts their sign-in) and keeps the delivery for an hour: once they link it, the session starts, and their browser goes on to it. The project is the one that takes the issue's team, in a workspace connected to the Linear workspace, whose tracker is Linear: one that takes the issue's Linear project comes before those that take the whole team; among several, Linear's repository suggestions for the issue choose, by the projects' primary repositories, and when they don't, fctl asks which in the agent session (an elicitation with the `select` signal) and waits for the answer. Projects the person can't start sessions in don't count, before any of that (a closer one they can't use hides none they can), and fctl says why it started nothing: no project takes the team, they may start sessions in none of those that do (named only when they may see them, as projects of other fctl workspaces connected to the Linear workspace aren't), or they have no Claude connection. When taking a delegation up fails on the way, fctl says so in the agent session and keeps the delivery for an hour: a reply there tries again. A session that works on the issue already (not archived or failed) that they may message gets the delegation as their message instead, so that a ticket's work stays in one session. Otherwise a new session starts (`crate::starts`, as a schedule's do), as the person, titled after the issue, on the branch `fctl/<number>-<key>-<title>` (`fctl_domain::tickets::branch`), with the project's GitHub access. Its first message says who asked, and gives the issue as Linear writes it for agents (`promptContext`: the issue, its comments, and the Linear workspace's and team's guidance for agents, cut at 40,000 characters), as information from the people who wrote it rather than instructions from fctl (`fctl_domain::linear::task`); the transcript shows it as the ticket (`UserMessage::ticket`). Where the project says so (on unless its maintainers turn it off), a delegated issue that hasn't started moves to its team's first started state (In Progress) before the session starts, as Linear asks of agents. A label (`explore` unless the project names another) asks for an exploration: the agent finds out what the ticket needs and replies with a plan, without changing code, until someone replies to go ahead. fctl links the agent session to the fctl session (`externalUrls`) and says it works on it. 6. **What goes back.** The agent's last message of each turn goes to the agent session as Linear's response, which Linear posts in the issue's thread, cut at 3,000 characters with a link to the rest (`fctl_domain::tickets::reply`), or as an elicitation when it ends with a question; the system prompt tells the agent so. A pull request the session finds is linked from the agent session and attached to the issue, with an action that says so; a step of the agent's (one of its tool calls) shows as an ephemeral action at most every two minutes, which keeps Linear from calling the session stale while the agent works (SessionObject, `crate::delegations::step`); and its todo list as the session's plan. Everyone who sees the issue sees those steps, so they say what the agent does without what could hold a secret (`fctl_domain::linear::step`): a command's description and not the command, a page's site and not its address, and searches without what looks like a token. A session that pauses on a usage limit, or fails, says so there. The transcript says what fctl posted (`ticket_replied`, `ticket_moved`). Nothing goes back for a session whose workspace no longer works in the Linear workspace. 7. **Replies and stops** (`prompted`). A reply in a started thread reaches its session's agent as a message of the person who wrote it, as fctl knows them, when they may message the session (`UserMessage::ticket`), resuming a stopped session on the subscription it ran on, as a person's message does (a failed session takes none, and Linear hears so); one from someone fctl doesn't know waits an hour for them to link their account, each person's apart. Their stop interrupts the agent's turn. Before a thread's session starts, only the person who made it moves it on: anyone else's reply would start it, or choose its project, on that person's subscription, so fctl says only they can, while anyone's stop ends it. Branches carry their ticket's key, so where the Linear workspace has Linear's GitHub integration, it links the session's pull requests to the ticket and moves the ticket on as they open and merge; agents write `Fixes ENG-123` (or `Part of ENG-123`) in their pull requests and leave the ticket's state alone unless asked. The project's Tickets tab shows the sessions tickets started lately (`GET /api/v1/projects/{id}/tickets`), and session lists and pages show each session's ticket (`Session::ticket`). ### Commits Commits are authored by the GitHub App's bot (`git_author` in the spec). Two project settings, both on unless a maintainer turns them off, decide who else they credit with `Co-authored-by` trailers: - **Credit people in commits** (`credit_people`): the person who started the session, with an address they chose: the commit email of the address they are in the workspace as, in Settings → Email addresses (`fctl account set --for <address> --commit-email …`), kept with the address and shown only to them. FleetDO puts their name and that address in the spec's `co_authored_by`; when the project credits nobody, it doesn't look the address up, so it never leaves the control plane. - **Credit Claude in commits** (`credit_claude`): Claude, as Claude Code does by default. fctl-agent turns them into Claude Code's `attribution` in Claude's user settings. With both, `attribution.commit` is Claude's trailer followed by the person's: ``` Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Ada Lovelace <1234+ada@users.noreply.github.com> ``` With Claude alone it stays unset, so Claude Code's own trailer, which names the model, ends commits. Without Claude it holds only the people's trailers, or nothing, and `attribution.sessionUrl` is `false`, so Remote Control sessions leave out the claude.ai session link too. Agents from before `credit_claude` always credit Claude. GitHub links a commit to the account that has the address, and a squash merge keeps the trailers of the commits it squashes. Claude Code's setting reaches the commits Claude makes, not those a script makes, and a repository's own Claude settings or CLAUDE.md can replace it. Sign-in emails never go into commits: `co_authors`, which older control planes filled with one, stays empty, and agents ignore it ([ADR 0011](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0011-commits-credit-people-with-an-address-they-choose.md)). ### Titles A session's title starts as the first line of its task. Once the agent understands the work it renames the session, and says in a sentence or two what it is about, with `fctl edit` in its sandbox; it does so again when the work turns to something else. fctl reaches fctl-agent over the session socket, and fctl-agent keeps the title and description in the session state it reports. SessionDO applies them when they differ from the agent's previous report, so a person's rename stands until the agent renames the session again, and writes them through to D1. fctl-agent tells Claude how in `~/AGENTS.md`, which Claude's user memory (`~/.claude/CLAUDE.md`) imports so it loads in every repository ([ADR 0007](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0007-agents-name-their-sessions-through-fctl-agent.md)). ### Pull request descriptions A project's settings say how much its agents write in the descriptions of the pull requests they open (`pull_request_descriptions`; Settings → Agents, `fctl projects set --pr-descriptions`): - **Balanced**, the default: a summary of what changed and why in one or two short paragraphs, with how it was verified and anything a reviewer must know (breaking changes, migrations, deploy steps, follow-up work) in a sentence each, then a "Worth confirming" section, when there is anything to confirm, with the decisions about behavior and the assumptions that the task didn't settle. - **Brief**: a few sentences without headings, and a sentence for anything a reviewer must know before merging. - **Full**: the whole story, as agents told it before the setting: how it works, the choices and the alternatives, how it was verified in detail. fctl-agent gives Claude the level's guidance in a section of its system prompt, after the rest of what fctl says and before the project's instructions, in sessions whose agents may push: whether or not the project asks them to open a pull request when they finish, since people ask for them too. The section takes the place of Claude Code's own format for pull requests, and says what goes at every level: each paragraph and list item on one line, since GitHub shows a line break inside a paragraph; readable link text, and related pull requests as their URLs alone in list items, which GitHub shows with their titles and states; the repository's pull request template where it has one; and a link to the session at the end. When later commits change what a pull request does, how it was verified or what is worth confirming, the agent rewrites its description (`gh pr edit --body-file`), so that it describes the pull request as it stands. Agents from before the setting describe pull requests as before ([ADR 0049](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0049-agents-write-balanced-pull-request-descriptions.md)). ### Model and effort A project's settings name the model its sessions run on (Claude's default; `opus`, `sonnet`, `haiku` or `fable`, Claude Code's aliases for the latest model of each family; or a model's ID) and the effort level (Claude's default, or `low` … `max`), which fctl-agent passes to Claude as `--model` and `--effort`. A session can ask for others as it starts (`CreateSessionRequest`, `fctl run --model … --effort …`), and the composers on the Agent Floor and a project's page have a list for each. They start on what the person asked for when they last started a session in the project, else on the project's settings, and a session asks only for what differs from those, so that where it takes the project's it follows them, when it resumes too, and the next composer starts on them again (`web/src/lib/models.ts`). D1 keeps what each person's last session in a project asked for (`project_choices`, written as sessions start), and projects return the caller's as `last_model` and `last_effort`. A person's rows go when they leave the project's workspace or delete their account. The session page and `fctl show` show the model and the effort level Claude works at. The model comes in Claude's `system/init` message, which in `-p` mode has no effort level, so fctl-agent asks Claude for it with the `get_settings` control request once Claude has started, and again after each turn, since it can change during a session: Claude Code's `/effort high`, in a message from the person who started the session, sets it without a model call, and so can the Claude apps over Remote Control. The answer's `applied.effort` is what Claude sends the API, after the session's `--effort`, Claude's settings and environment, the organization's limits and the model's default: `low` … `max`, or none for a model that takes no effort level. fctl-agent reports it in the session state, and SessionDO writes it through to D1's `claude_effort`. `effort` stays the level the session asked for, which its runs pass to Claude, so a resumed session still follows its project's setting. Claude Code before `get_settings`, and agents before this, report none, and the session shows no effort level. The Work section of the session page and `fctl show` show how full Claude's context is, against the window and where Claude compacts the conversation on its own, and how often it did. SessionDO counts the context with the other counters: the input of the conversation's latest API call (subagents' calls have contexts of their own), cached or not, as Claude Code's status line counts it, and what a compaction left (`compact_boundary`'s `post_tokens`) until the next call says. The window and the threshold depend on the model, Claude's settings and environment and its experiments (Claude Code 2.1.308 gives Opus 5.5 a window of 1M tokens and compacts at 967K), so fctl-agent asks Claude for them with the `get_context_usage` control request once Claude has started, and again after each turn, since `/model` changes them. It asks for a `summary`, which Claude answers from the last call's usage and its own estimates, without asking the API to count each part of the context. fctl-agent reports the answer's `maxTokens` and `autoCompactThreshold` in the session state (`ContextWindow`), and SessionDO keeps them in the session's stats. Claude doesn't compact before the window is full when auto-compact is off or when it leaves that to the API (`autocompactSource` `auto`: it compacts once the API says the prompt is too long, and its `/context` shows no buffer for it), so fctl-agent reports no threshold then. Claude Code before `get_context_usage`, and agents before this, report no window, and the session shows the context alone. ### Event flow and reliability - fctl-agent assigns `agent_seq` to every event and appends it to a journal in the sandbox. The host forwards events in batches; SessionDO stores them, assigns the canonical `seq`, broadcasts them, and acknowledges `agent_seq`. After any reconnect SessionDO announces the last acknowledged `agent_seq` and the host replays the journal from there. People's messages, interrupts and status changes are SessionDO's own to record: from an uplink, where a host or what runs in its sandbox could send them as anyone, they are acknowledged and dropped. - SessionDO stores events in `agent_seq` order. After a reconnect, newer events can arrive before the replay of older ones, which went over a link to the agent or an uplink that closed: they wait for it, for at most a minute, rather than acknowledge the older ones unseen (`crates/fctl-domain/src/journal.rs`). - Streaming deltas (`stream_event`), tool heartbeats (`tool_progress`) and Claude Code's word on how far the agent's thinking got (`system/thinking_tokens`, about once a second while it thinks, which the session page counts after "Thinking") are forwarded but not stored. SessionDO keeps the response in progress in memory so late viewers see it, with only the newest thinking progress: each counts the whole thought (`fctl_domain::events::Live`). Older agents store the thinking progress; SessionDO acknowledges it and drops it. - The session's counters (`SessionStats`) move as the agent works. Each API call's input and cache reads count as its response starts (`message_start`), or with its stored messages if that stream event went missing, and its output with the `message_delta` that ends it. The input of a call of the conversation's own, cached or not, is what Claude's context holds. Claude reports each several times, so SessionDO remembers the calls it counted. A turn is under way while the session runs (`turn_started_at`), so viewers count its time up. Each turn's `result` adds its duration and settles the tokens on Claude's own totals (`modelUsage`), which also have the calls that stream nothing and the output lost while the uplink was down (`crates/fctl-domain/src/events.rs`). - Host status (`provisioning`, `preparing`) is sent as idempotent state, not as sequenced events. - Deliveries (messages, interrupts, stops) wait in SessionDO's outbox until the agent confirms them; the agent remembers delivered ids. The host keeps them too, and sends the unconfirmed ones again over a new link to the agent. ### 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. The session page pages it in as the reader scrolls up near the start of what it has, without a press, and as it opens, too, until it has about a screenful to show: the recent events can show next to nothing, since the tool calls in a row fold into one line, and older agents stored Claude Code's word on how far the agent's thinking got, about once a second. The Worker checks access as the socket opens, and SessionDO checks it again while it stays open: the token it was opened with and the role it gives, on the viewer's pings at most once a minute and before every message or interrupt (`recheck_viewer`). A viewer whose person can no longer see the session (they left the workspace or were removed, their role or share went, they signed out or were disabled) closes with 4403, after which the apps stop reconnecting; one whose role changed gets it in its next `session` frame. A viewer that stopped pinging closes after two minutes at the next broadcast and reconnects through the Worker. The snapshot and `runner_connected` frames say whether the agent's work can come through: - **The runner is connected** when its uplink is open and SessionDO heard from it within the last minute. Hosts ping every 20 seconds, but one that is frozen or cut off (a laptop gone to sleep) can leave its socket open for a long time. SessionDO has no timer while its sockets hibernate, so viewers' pings, every 25 seconds, notice an uplink that went quiet. Otherwise the frames say when the runner was last heard from. - **The runner can't reach the agent** when its host says so (`UplinkUp::AgentLink`): once it has known for 10 seconds that no link to fctl-agent works, with when it last heard from it and what went wrong. Hosts say it again after every reconnect, and SessionDO forgets it when the uplink closes. Older hosts never say it. The session page and `fctl attach` say which, and since when, once it has lasted 10 seconds (a host that restarts reconnects within them), and show nothing in progress meanwhile. ### Search People find what sessions said in two places ([ADR 0056](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0056-sessions-are-searched-in-an-index-per-workspace.md)): - **In every session they see.** ⌘K, past a couple of letters, searches what the sessions a person sees say, and so do `fctl search` and `GET /api/v1/search?q=` (`?workspace=`, `?project=`, `?limit=`): their titles and descriptions, people's messages (and fctl's) and the agents' replies; not tool calls, what they returned, thoughts or subagents' work (`fctl_domain::search`). It finds what was typed as typed, but for case, accents and runs of whitespace, from three characters (two in Chinese, Japanese or Korean). Sessions with it in their title come first, then the others, the most recently mentioned first, each with snippets of about 160 characters of where it matched (its title or description, then its newest messages and replies, three at most, and where in them: `ranges`, in UTF-16 code units), how many of its documents match, and whether it is archived. Review agents' sessions aren't searched. - **In one session.** ⌘F (Ctrl+F) on a session's page opens a find bar under its header, and a second ⌘F there gives the browser's own. It finds what was typed, as search does, in all the transcript shows, folded or not: people's messages, the agent's replies, what its tool calls were about and their commands, the plan, its thoughts, what its subagents were asked, did and reported, the notes it kept, its retrospectives, notices and why the session stopped; not what calls returned, files or diffs (`web/src/lib/find.ts`). It counts the matches, the newest first, lights them up (the CSS Custom Highlight API), and steps up the page with Enter, ↑ or ⌘G, down with Shift+Enter, ↓ or ⌘⇧G. The match it gets to comes into view with what folds it opened, once typing pauses, or at once when the reader steps to it. While it has found nothing, it loads earlier activity to look in it too, 4,000 events at a time and 40,000 at most; once it has, the count says when there is more ("3 of 12+"), and a step past the oldest match loads more. A load that brings nothing waits for the reader to step back again. Words selected on the page are what it finds when it opens. A result of a search of all sessions opens its session's page with the bar on what was typed, at the newest message or reply it matched (`?find=…&at=<seq>`, which the page then takes out of its address). The index: 1. Each workspace's **SearchObject** keeps its sessions' documents in its SQLite storage: the title, the description, and each message and reply (a reply as the page shows its Markdown, without its syntax or link addresses; its first 10,000 characters, with each run of whitespace as one space), with its session, project, event (`seq`) and, for a message, who wrote it. An FTS5 index with the `trigram` tokenizer and `remove_diacritics` finds text of three characters or more in them; two characters of Chinese, Japanese or Korean go through them one by one. 2. A SessionObject notes what it stores that the index takes (a message with words, a reply of the agent's own that says something, a new title or description), and its alarm puts it in its workspace's index five seconds later, with whatever came meanwhile (`/put`, after the last event it put there, 500 events at a time). What the index didn't take it tries again a few times, waiting longer each time. It records in D1 whether the index has all its events (`sessions.search_indexed`), and the hourly job asks those whose index doesn't, the most recently active first, 200 a run, to index all they hold again, from their first event (`/index`): the sessions from before search, those whose index fell behind, and every one after `UPDATE sessions SET search_indexed = 0`, which builds a lost index again. A SessionObject that is purged takes its documents out of the index first (`/forget`). 3. A search reads from D1 the projects the caller sees in each of their workspaces, as the project list does (a limited token's places and roles too), and the sessions of other projects shared with them, asks each of those workspaces' indexes at once for the sessions among them whose documents match (`/search`), and reads the sessions it found from D1, keeping those the caller sees now. A workspace whose index fails leaves the others' results; all failing is an error. ### Attachments A task, message or resume can carry files: up to 10, of at most 20 MB each. 1. Each file uploads on its own, the bytes as an `application/octet-stream` body, into the project's FilesDO: `POST /api/v1/projects/{id}/attachments` to start a session, `POST /api/v1/sessions/{id}/attachments` for a message in one (for anyone who may message it). The answer is an `Attachment` (id, name, media type, size, and pixel size for images). 2. The message sends the ids. The control plane claims the uploads for the session (they must be the sender's and not sent in another session) and records them on the user message and the delivery; a task's files are kept on the session row and travel in the `SessionSpec`. Uploads no message claims are deleted after a day. 3. Before forwarding a message, or configuring the agent with the task, the host fetches the files with its runner token (`GET /api/v1/runner/sessions/{id}/attachments/{attachment}`), in the order the messages came, and hands them to fctl-agent as `file` frames. 4. The agent saves each file as `~/attachments/<id>/<name>` in the sandbox, outside the repository, and lists the paths after the message's text. Images Claude accepts as they are (PNG, JPEG, GIF and WebP within the API's size limit and 2000 px a side, 5 MB of them per message) go into the message too; Claude reads anything else with its tools, which resize large images. Viewers of a session read its files with `GET /api/v1/sessions/{id}/attachments/{attachment}`. It answers a single byte range (`Range`, `206 Partial Content`) from only the chunks that hold it, because Safari plays a video only from a server that does: the session page plays attached videos, and shows images, in a lightbox. Hosts and agents announce the `attachments` feature in their hellos; an older one passes the text alone, and the transcript says so. ### Published screenshots Files go the other way too: agents show people what a pull request changes with screenshots in its description. GitHub takes those uploads (`gh pr edit --attach`) only with a person's token, and sessions have the GitHub App's, so in projects that publish screenshots the agent publishes them on the control plane instead and puts the links in the pull request ([ADR 0014](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0014-agents-publish-screenshots-at-unguessable-links.md)). 1. `fctl publish shot.png` in the sandbox sends the image to fctl-agent over the session socket, which checks that it is a PNG, JPEG, GIF or WebP of at most 5 MB (the most GitHub's image proxy fetches) and hands it to the host as a `publish` frame. 2. The host uploads it with its runner token (`POST /api/v1/runner/sessions/{id}/published`). The control plane checks that the session is assigned to that runner and that publishing is allowed, stores the bytes in the project's FilesDO, and indexes them in D1's `published_files` by the SHA-256 of a new token of 256 random bits. Its answer, a `PublishedFile`, has the link, `https://<instance>/api/v1/published/<token>/<name>`, and when it expires. 3. fctl-agent answers fctl, which prints the link and Markdown that shows it, and adds a `screenshot_published` notice with the file to the transcript. 4. Anyone with the link gets the image (`GET /api/v1/published/{token}/…`): GitHub's image proxy for the pull request, the transcript in the web app. Responses are `public, no-cache` with an `ETag`, so caches ask every time, `nosniff`, sandboxed by their Content-Security-Policy, `Cross-Origin-Resource-Policy: cross-origin` and `noindex`. Publishing is off twice over by default: the instance's admins allow it (Admin → Settings, `InstanceSettings.publish_screenshots`) and each project's maintainers turn it on (`publish_screenshots` in its settings), which also picks how long links work: 30 days unless `screenshot_days` says 1 to 60. The control plane checks both when a screenshot is published and whenever a link is opened, so turning either off takes the screenshots offline at once, until they expire. A session publishes at most 100. The FilesDO's alarm deletes a screenshot's bytes when it expires and the hourly cron trigger its row; people who manage the session delete one sooner (the transcript, `fctl unpublish`, `DELETE /api/v1/published/{token}`). The setting reaches the agent in its `SessionSpec`, only when the instance allows it too, and through hosts that know it: an older host leaves it out of the agent's configuration, so its agents never offer `fctl publish`. When it is on, and the sandbox's fctl has the command, `~/AGENTS.md` tells Claude to try `--attach` and publish when GitHub refuses. ### Project memory Every session starts in a new conversation, most in a new sandbox, so what an agent learned that the repository doesn't say went with it. A project keeps a memory instead: notes its agents, and its people, write for the agents of its later sessions, such as a command that works, a trap, how its tests or deploys really behave, or a decision people made in conversation ([ADR 0034](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0034-agents-keep-a-project-memory-and-record-retrospectives.md)). D1 keeps them (`memories`): the text (Markdown, at most 2,000 characters), the session whose agent wrote it and the person who started that session, or the person who wrote it, who last changed it, and for a stale note when, why and who marked it, and the note that took its place or that it repeats (`fctl_domain::memory`). 1. **What agents start with.** As FleetDO builds a run's spec, it adds the project's current notes, newest first, as many as fit in 24 KB and 200 notes, and how many more there are (`SessionSpec.memory`, `memory::for_prompt`): about what Claude Code loads of its own memory. fctl-agent lists them in `~/AGENTS.md`, each with its id, where it came from and how old it is, as notes that don't override the person's request or the rules, and says how to keep them. A resumed run starts with the memory as it is then. It also sets `CLAUDE_CODE_DISABLE_AUTO_MEMORY=1`: Claude Code's own memory would live in the sandbox's `~/.claude/projects/` and go with it. 2. **What agents do.** In the sandbox, `fctl memory` lists the notes as they are now, those other sessions added since the run started too; `fctl memory add` writes one, in place of others that go stale with `--replaces` (as many as 20, for a note that says what overlapping notes did); `fctl memory stale` marks one stale, with the reason agents must give (not one someone else marked stale already); and `fctl memory duplicate` marks one stale as a duplicate of another, a current note that agents go on starting with, and links the two. Agents can't change or delete notes. fctl reaches fctl-agent over the session socket, which hands the call to its host (`AgentToHost::Call`); the host sends it with its runner token (`POST /api/v1/runner/sessions/{id}/calls`, an `AgentCall`), and the control plane checks that the session is assigned to that runner, that the runner still serves its workspace and that the project keeps a memory, as for published screenshots. The answer comes back the same way, and fctl-agent adds a `memory_added` or `memory_stale` notice with the note to the transcript, one for each note a new one replaced. The host retries reading the memory and marking a note stale (a duplicate too) on errors that may pass, and not a new note, which the control plane may have kept already. The first note a new one replaces travels as `replaces` and the others as `also_replaces`, and a duplicate as `mark_stale` with `duplicate_of` and the reason `Duplicate of mem_…`: a sandbox's fctl may be newer than its host, and fctl-agents, hosts and control planes from before them replace the first note alone (fctl says which notes are still current) and mark a duplicate stale with that reason. 3. **What people do.** Everyone who sees the project reads its memory (its Memory tab, `fctl memory -p …`, `GET /api/v1/projects/{id}/memories`); editors and up add notes, in place of others too, change them, mark them stale (duplicates too) or current again and delete them (`POST /api/v1/projects/{id}/memories`, `PATCH` and `DELETE /api/v1/memories/{id}`), which the workspace's audit log records. A project keeps at most 500 notes, stale ones included. Session pages ask for the notes their agent wrote or marked stale (`?session=`), and the web app reads the memory again when a session of the project changes status, as when a turn ends. Deleting a note leaves the transcripts that showed it as they are. The **Project memory** setting (`project_memory`, on by default) decides whether agents keep it. Off, the spec has no memory, agents hear nothing of it and the control plane refuses their calls; the notes stay for people. An update that leaves the field out keeps the stored value. Hosts and agents from before it never offer `fctl memory`, nor does a sandbox whose fctl lacks the command (fctl-agent asks `fctl memory --help` and says so once in the transcript's log). ### Retrospectives As an agent finishes a piece of work (it opened or updated a pull request, answered what was asked, or stops because it is blocked), `~/AGENTS.md` asks it to record a retrospective for the project's people: how the work ended (`completed`, `partial` or `blocked`), what it worked on, the approach it took, what got in the way and what a future agent should know (`fctl retro --outcome … --worked-on … --approach … --challenges … --insights …`). The call travels as the memory's do, under the **Retrospectives** setting (`retrospectives`, on by default). D1 keeps it with its session (`retrospectives`), at most 50 a session (`fctl_domain::retrospectives`), and the transcript shows it as a `retrospective` notice. A retrospective is of a piece of work, not of a turn ([ADR 0038](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0038-agents-keep-one-retrospective-for-each-piece-of-work.md)). When a later turn carries on with work the session recorded one for (review comments, a failed check, the answer that unblocks a blocked one, the rest of a partial one), `~/AGENTS.md` asks the agent to update that one rather than record another: `fctl retro update rtr_… --outcome completed …` replaces the outcome and the parts it gives and keeps the others, and `fctl retro list` in the sandbox lists the session's own, with their ids, which the agent checks before it records one (a resumed run may start without its conversation). The control plane changes only retrospectives of the calling session, in place, and keeps when (`updated_at`); the transcript shows the retrospective as it now is in a `retrospective_updated` notice, and keeps the earlier notices as they were. A new one is for different work. Where the sandbox's fctl can't update them (fctl-agent asks `fctl retro update --help`), `~/AGENTS.md` asks instead for one for each follow-up that changes the work. The session page shows the newest beside the transcript and all of them a click away, each saying when its agent last updated it, and the project's Retrospectives tab lists the project's, newest first (as they were recorded), with how many ended each way and a filter by outcome, leaving out those of archived sessions (`GET /api/v1/projects/{id}/retrospectives`, a page at a time after the id of the last one, which sort as they were recorded; `GET /api/v1/sessions/{id}/retrospectives`; `fctl retro list`). Editors put an insight in the project's memory from there. People who manage a session delete its retrospectives (`DELETE /api/v1/retrospectives/{id}`), which the workspace's audit log records; the transcript keeps what the agent recorded. ## Notifications A session notifies the people who follow it when it waits for a reply (a turn ends: `running` → `idle`), pauses on a usage limit, or fails. Starting or resuming a session follows it, unless the person turned that off in Settings; anyone who can see a session can follow it (the bell on its page, `fctl follow`). Notifications are Web Push messages (RFC 8030) to the browsers where people turned them on, so they need no account with a push provider; on iPhone and iPad they reach fctl on the Home Screen. 1. SessionDO sees the change. It leaves out the people who can see the session right now (a viewer whose tab is visible: the web app sends `visibility`) and whoever interrupted the turn, and asks D1 for the other followers who can still see the session, want this kind of news (`notification_settings`) and have a browser for it. A finished turn carries the start of the agent's last message. 2. It hands the news to each one's NotifyDO, which waits ten seconds for more, so that a burst goes out as one, and sends at most one notification a minute: what arrives in between goes out together as a digest ("3 sessions need you"). Sessions that moved on meanwhile (a reply made one stop waiting) are left out. 3. The control plane encrypts the message for each browser (RFC 8291, `aes128gcm`) and posts it to the browser's push service, signed with a VAPID token (RFC 8292). The instance's VAPID key is made on first use and kept in D1's `settings`, sealed with the master key; WebCrypto does the elliptic-curve work. Push services see when a message goes and how big it is, not what it says. 4. The service worker (`web/public/sw.js`) shows it. A click opens the session, in a window of the app that is already open when there is one; a digest replaces the notifications about the sessions it sums up. A browser's subscription (`push_subscriptions`) belongs to the sign-in that made it: signing out, revoking that sign-in or its expiry deletes it, and so do removing the browser in Settings and a push service that answers it is gone. When the app starts in a browser whose subscription the server no longer has for that sign-in, it ends the subscription there too, so it never comes back by itself; when the instance's VAPID key changed, it subscribes again with the new one. ## Claude credentials Each user connects their own Claude subscription; a session always runs on the subscription of the person who started it: the connection they chose for the session's workspace, else their default one. Three kinds are supported: | Kind | How it is created | Where the token is | Remote Control | |---|---|---|---| | Long-lived token | `claude setup-token` on any machine, pasted into fctl | the control plane | 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 | the control plane | yes | | Token on your runners | `claude setup-token`, then `fctl host claude set-token` on each of your runners | your runners only | no (inference-only scope) | 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 of the person it runs for, and nobody else can open it there. SessionDO names that person in each Remote Control state the agent reports (`RemoteControlState.user`): who started the session, or who resumed it last, which the resume tells it (`ControlState.claude_user`). The web app shows the links, and the transcript's note, to that person alone; states from before it named anyone count as the starter's. A full login also brings the person's claude.ai connectors (Claude Code fetches them with the `user:mcp_servers` scope). New projects start with them off, unless the request that creates one sets `claude_ai_connectors` in its settings; a project can turn them on or off there. When off, the control plane then adds `ENABLE_CLAUDEAI_MCP_SERVERS=false` to the environment in the session's spec, which every fctl-agent passes to Claude, and fctl-agent writes `disableClaudeAiConnectors` into Claude's user settings, which no repository settings can undo. An update that leaves the field out keeps the stored value, so a client from before it can't turn connectors back on. Agents can list and message other Claude Code sessions with Claude Code's [cross-session messaging](https://code.claude.com/docs/en/cross-session-messaging) (the `ListAgents` and `SendMessage` tools). On its own machine, Claude Code finds only the sessions registered in its `~/.claude/sessions`, and every sandbox has its own home directory, so an agent finds there only the Claude sessions in its sandbox, such as its subagents. Claude Code finds sessions on other machines and in the cloud only while connected to Remote Control, so a session on a long-lived token can't list, read or message any of the person's other sessions, whether in fctl, on their own machines or at claude.ai/code. A session on a full login joins Remote Control unless its project turns it off, and its agent can then list the person's other Remote Control sessions (fctl sessions on a full login, and those on their own machines) with their titles and status, and message them. The `process` driver isolates nothing: there an agent can reach whatever the host's user can. ### Tokens on your runners People who would rather not hand their token to the control plane keep it on their runners ([ADR 0050](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0050-claude-tokens-can-stay-on-their-owners-runners.md)): 1. **On each runner**, `claude setup-token` makes a token (there, or on any machine with Claude Code), and `fctl host claude set-token` keeps it in `claude-token` next to `host.toml` (mode 0600; `claude_token_file` in `host.toml` puts it elsewhere). `fctl host claude status` says whether the runner keeps one and whether the control plane knows, and `fctl host claude remove-token` removes it. The host says in its hello and each heartbeat whether it keeps one and when it was saved (`HostHello.claude_token`, `RunnerClaudeToken`), never what it is; FleetDO keeps that in the runner's hello, and the runner's owner sees it (`Runner.claude_token`). Hosts too old to keep one say nothing. 2. **In fctl**, they connect "On your runners" (Settings → Claude, `fctl claude connect --on-runners`, `POST /api/v1/me/claude/on-runners`): a row in `claude_credentials` with `on_runners` set and no secret, of kind `setup_token`, one per person, which becomes their default. 3. **Its sessions run only on their runners that keep a token.** It keeps them on their person's runners as **Only on my runners** does, in every workspace (`fctl_domain::schedule::only_theirs`, for `runs_for`, and so for `spec::runner_serves` too), and FleetDO gives them only to those of the person's runners whose host keeps a token (`Candidate::slot`). One that waits says why (`fctl_domain::schedule::TokenWait`): none of their runners is in the workspace, none of those is online, or the ones online keep none, or run an fctl too old to keep one. 4. **The host fills it in.** The spec carries the connection without a token (`ClaudeCredential.on_runners`, `access_token` empty), and so do the fresh credentials FleetDO answers with; the host puts the token it keeps in before the agent gets either (`claude_token::fill`), and refuses a session when it keeps none, saying what to run. A token replaced on the runner reaches the runs that start after, and running ones when Claude asks for a fresh token; one removed stays with the sessions running there until they stop. Hosts take Claude tokens out of what agents send to the control plane, for every session: the token a session started with, those it got since and the one the host keeps become `[Claude token, redacted by fctl]` in every frame from the agent (`claude_token::Secrets`), so that neither a transcript nor what viewers see live shows one an agent printed or repeated. Claude's streamed deltas split what it writes anywhere, so of each content block the end that could start a token waits for the next delta, and goes out before the block ends if it doesn't (`claude_token::StreamRedactor`). The agent has the token in its environment all the same, so whoever directs it can still have it send the token elsewhere, or write it in a form the redaction doesn't know ([Security](https://fctl.sh/docs/security.md#sandboxes)). 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. ### Plan usage Settings shows how much of each connection's plan is used, and the Agent Floor shows it for the connection new sessions run on, so people see what is left before they start work. Sessions report it; fctl asks Anthropic for nothing. Claude Code writes a `rate_limit_event` when a limit's status changes and, in recent versions, whenever the five-hour or weekly window moves by a percent (`unifiedWindows`). fctl-agent passes it on like every other Claude message. 1. SessionDO turns each one into readings, one per limit (`fctl_domain::usage`): the five-hour window, the week, and a model's own weekly limit when Claude names it, each with the share used, when it resets, and Claude's status for the limit closest to stopping requests. Paid extra usage is not a plan limit and is left out. 2. It keeps them with the Claude credential the session runs on now (a resumed session runs on the resumer's), in D1's `claude_usage`: one row per credential and limit, which a later reading replaces. 3. `/auth/me` returns them on each credential (`ClaudeCredentialInfo.usage`, `fctl claude status`), and FleetDO sends the credential's owner a `claude_usage` feed message, so Settings and the Agent Floor change while sessions run. Readings change only while sessions run, so the apps say when a session last reported them, and a window whose reset time has passed counts as started over. Removing the connection deletes them. Hosts and agents need no update: they already pass every Claude message on. The apps go by the share used. From 80 % of a limit, the Agent Floor says how much of it is used, in amber, and from 90 % in red. At 100 %, or when Claude refuses requests (`rejected`), the limit is reached, and only then does a session's transcript say so. Claude's warning status (`allowed_warning`) doesn't count: Claude Code sends it when a limit goes faster than its window passes, as with a quarter of the week used in its first day. ## 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. The image is the project's or, without one, the host's: unless `host.toml` sets another, the default image of the host's own version, `ghcr.io/hyperstructure/sandbox:<version>`. hyperstructure/sandbox's Image workflow publishes it for arm64 and amd64 within the hour after every release, and rebuilds the latest release's when Claude Code has a newer release, since fctl turns off Claude Code's auto-updater. The host pulls the image when it starts and when a sandbox needs one it doesn't have, and falls back to `latest` when its version's can't be pulled. On the default image, it pulls its tag again when it starts and every 6 hours, unless the image there was built on the host: only the Image workflow sets the `org.opencontainers.image.revision` label. It also removes the default images that sessions no longer start from, without force, so that Docker keeps those a sandbox uses (`crates/fctl-host/src/refresh.rs`, [Sandbox images](https://fctl.sh/docs/sandbox-images.md)). `fctl host image build` builds it on the host instead, from that repository's `main`, which it downloads; the Lima driver's base VM gets the image's `provision.sh`. A project names an image (`ProjectSettings.image`) or a file in its repository to build one from (`image_build`: a Dockerfile and its context, or a dev container configuration). Hosts send the images they run besides their own (`HostHello.allowed_images`: references, `/*` prefixes, digest pins; absent: any) and the repositories they build images from (`allowed_builds`; absent: none, and always absent with Lima or the process driver). FleetDO reads each queued session's image with its size (`fctl_domain::image::ImageWant`) and only assigns it to a runner whose lists take it (`fctl_domain::schedule::pick`); a session that none online takes stays queued with the reason. The host checks its lists again before it accepts a session. To build (`crates/fctl-host/src/build.rs`), it reads the file at the tip of the project's default branch through GitHub's API with the session's token, keys the build by the file, the Dockerfile and the git tree of the context, and builds only when no image has that key: it downloads the repository's archive, unpacks the context and the Dockerfile, and runs `docker build` or `container build`, one at a time, within `build_timeout_minutes`, removing an image over `max_build_mb`. It records each project's last build in `builds.json` in its data directory, removes the one before, and starts from the last build while GitHub can't be reached. | 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 | A sandbox gets the CPUs and memory its project's settings ask for, or the host's default. Hosts send the default and a per-sandbox maximum in their hello (`HostHello.sandbox`): the configured `max_cpus` and `max_memory_mb`, within what the sandboxes run on (the Docker daemon's VM with Docker, otherwise the machine). FleetDO only assigns a session to a host whose maximum covers its size; one that fits nowhere stays queued with the reason, and the sessions queued behind it may start meanwhile. Hosts that send no limits (older versions) take any size. The host passes the size it applied to fctl-agent (`AgentConfig.sandbox`), which tells Claude: with Docker, `nproc` and `free` show the VM, not the sandbox's cgroup limits, and with Apple's `container`, `nproc` counts one CPU more than the sandbox may use. The process driver limits nothing. An Apple `container` sandbox is a VM of its own. Apple's runtime gives the VM one CPU and 128 MiB more than the sandbox's size, for the guest kernel and its init, and holds the container to the size with cgroups. It sets the VM's clock from the Mac's every 30 seconds, so the clock is right again within half a minute of the Mac waking, and the VM resolves names through the Mac. The root filesystem is a sparse 512 GiB disk image cloned from the image's, so `df` shows about 500 GB free whatever the Mac has. The image grows as the session writes, and shrinks only where the guest discards blocks: the guest mounts it without `discard`, and the container can't run `fstrim` without `CAP_SYS_ADMIN`, which it doesn't have. So as the host stops a sandbox, it runs `sync` in it, then `container clean` (Apple's `container` 1.4 and later), which has the VM's init trim the root filesystem, and the Mac gets back the space of the files the session deleted ([ADR 0025](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0025-hosts-trim-apple-sandboxes-as-they-stop-them.md)). While a sandbox runs, it holds the space of the files deleted in it; with an older `container`, until the host removes the sandbox. Apple's `container` can fail to stop a sandbox and leave it half stopped: it still lists the sandbox as running, but refuses `exec`, `cp` and `clean`, and `container start` does nothing. It does when a program in the sandbox, Podman say, made cgroups of its own: as the VM's init stops the container, writing `cgroup.kill` in one of them fails (`EOPNOTSUPP`). A second `container stop` finishes the stop, so the host stops a sandbox again when stopping it fails while it is still listed as running. Before a session starts in a sandbox listed as running, the host runs `true` in it, and stops and starts one that refuses. 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. The host pings the agent every 15 seconds and replaces a link that carries nothing for a minute, or gets no hello within 30 seconds of attaching: it kills the exec, starts fctl-agent again if it is gone, and attaches again, waiting longer after each failure (`crates/fctl-host/src/liveness.rs`). Once it has known for 10 seconds that no link works, the session's viewers hear that the agent can't be reached, and since when ([Viewers](https://fctl.sh/docs/architecture.md#viewers)); a host that wakes from sleep to silent links attaches again within them. A host that restarts starts fctl-agent again too, before it attaches, in case the sandbox restarted with it. A supervisor holds a lock on `~/.fctl/agent.lock` while it runs, so a second one sees the first and exits without connecting to its socket: the supervisor takes every connection for a host link, and the newest replaces the host's. Agents from before the lock are found on the socket instead. Driver commands that hang, as they do in an overloaded Docker VM, are killed after a minute (image pulls and VM boots after 30), and the control connection never waits on one: the hello reports what the drivers said when the host last probed them (at start and every five minutes), and sweeps and cleanups of sandboxes run in tasks of their own, so heartbeats keep flowing. A host that starts while its driver doesn't answer waits to connect until it can list its sandboxes, rather than report sessions as gone that may still run. A driver command that can't start says as little as one that hangs about the image or sandbox it asked about: the host reports the error instead of pulling the image, falling back to `latest`, or taking a sandbox it couldn't stop or remove for gone. A second socket beside it, the session socket (`FCTL_SESSION_SOCKET` in Claude's environment), serves programs in the sandbox: `fctl edit` names and describes the session there, `fctl publish` publishes screenshots, `fctl memory` and `fctl retro` keep the project's memory and record and update retrospectives, `fctl resolve` resolves review threads ([GitHub access](https://fctl.sh/docs/architecture.md#github-access)), and `fctl ticket` reads and writes the project's tickets ([Ticket trackers](https://fctl.sh/docs/architecture.md#ticket-trackers)), with no fctl credentials in the sandbox. A stopped session keeps its sandbox, so resuming it on the same host continues Claude's conversation ([Idle sessions](https://fctl.sh/docs/architecture.md#idle-sessions)). The host records when each one stopped (`stopped.json` in its data directory) and removes those stopped for longer than `keep_stopped_hours` (`crates/fctl-host/src/expiry.rs`), when it starts and every hour. It also keeps `min_free_disk_gb` free on the disk where the sandboxes' files are: Apple's `container` app root (from `container system status`), Lima's home, Docker's root directory, or with Docker on macOS the disk in its VM, where `df` runs in a short-lived container of the host's image, and the data directory with the process driver. It measures every 30 seconds (in Docker's VM every five minutes). Below the floor: - The sweep removes the other stopped sandboxes too, those stopped longest first, one at a time, and measures after each, so that it stops once there is room. A session that starts in one meanwhile keeps it. - The host takes no new sessions, only resumes of the sessions whose sandboxes it has. Its hello carries the floor (`HostHello.min_free_disk_mb`), and FleetDO compares it with each heartbeat's `disk_available_mb`: a runner below it gets resumes into its sandboxes, as while it drains, and no other session (`fctl_domain::disk`, `schedule::pick`). The host refuses the others itself, for a control plane that hasn't had its heartbeat yet. Unlike a drain, it stops no idle sessions early, and it ends when there is room again ([ADR 0019](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0019-hosts-make-room-when-their-disk-runs-low.md)). Older hosts send no floor and remove sandboxes by age only. Resuming a session whose sandbox is gone, or resuming on another host, creates a new sandbox on the session's branch: the agent finds no conversation to resume, so Claude starts a new one with the session's original task, and the agent numbers its events after the ones the control plane already has (`first_agent_seq`). ### Package caches A project can share its package managers' downloads between its sessions (`ProjectSettings.package_caches`, off by default): each host keeps caches for the project, which its sandboxes there mount, so that a setup script fetches only what is new ([ADR 0048](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0048-hosts-keep-package-caches-for-projects-that-share-them.md), `crates/fctl-host/src/caches.rs`). Review sessions never get them, whatever the project says: the control plane turns the setting off in their spec, and the host checks. - **Where.** With Docker, a named volume per project, `fctl-cache-<project id>`, on the daemon's disk like the sandboxes. With Apple's `container`, a directory in the host's data directory, `caches/<project id>`, that each sandbox bind-mounts: Apple's named volumes are ext4 images, which two running VMs must not mount at once. Both appear at `/var/cache/fctl`. The process driver's agents use the directory where it is, and Lima's VMs get none. The mount is made with the sandbox, so a sandbox made before its project turned caches on gets none until the session's next one. Docker makes a new volume's root root's, so the host gives it to the image's user (`id -u` in the sandbox, then `chown` as root) as it creates the sandbox. - **In the sandbox.** The host passes the directory and the limit to fctl-agent (`AgentConfig.cache`). fctl-agent checks it can write there, and if not, logs why and leaves the package managers alone. Otherwise it sets their variables before the project's environment, for the setup script and for Claude (npm, pnpm 11, Corepack, Yarn, Bun, uv, pip, Go, Playwright, and `FCTL_CACHE_DIR`), writes pnpm 9 and 10's store to `~/.config/pnpm/rc`, and links Cargo's registry, git checkouts and lock files there, since `CARGO_HOME` also holds Cargo's tools. What the image sets or keeps there already stays. On another disk than the sandbox's, uv copies from its cache rather than hardlinking (`UV_LINK_MODE=copy`), which it would warn about at each install. The system prompt tells Claude where the caches are and to leave them be. - **Bounds.** `<data dir>/caches.json` records when a session last started with each project's caches, what they took when last measured, and which sandboxes were made with them, so that after a restart the host still knows whose caches its sandboxes use. The sweep that removes stopped sandboxes (when the host starts, every hour, and while the disk is low) measures the caches used since their last measurement and empties those past `max_cache_gb` (10 GiB by default), and removes those no session started with for `keep_caches_days` (14), keeping them, emptied, while a sandbox here may mount them again. Below `min_free_disk_gb`, it empties caches, least recently used first, before it removes stopped sandboxes. With Docker, measuring (`du`) and emptying (`find -delete`) run in a short-lived container of the host's image, without a network; Docker keeps a volume a container mounts, stopped ones too. The sweep never touches the caches of a project with a session here, and a session that starts while its project's caches are measured or emptied waits for that. ## Protocols All protocols are JSON with a `type` tag and live in `fctl-proto`: - `api` — REST request and response bodies. - `viewer` — SessionDO ⇄ browser/CLI. - `runner` — FleetDO ⇄ host (control connection). - `uplink` — SessionDO ⇄ host (one per session). - `agent` — host ⇄ fctl-agent (attachments' bytes travel here as `file` frames, published screenshots' as `publish` frames, what agents ask of the control plane about their project as `call` frames), and `fctl` ⇄ fctl-agent in the sandbox (the session socket). - `event` — the session event model shared by all of them. 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 - `app.<domain>` — fctl-control and the web app (one Worker). - Or, without Cloudflare, the same Worker and web app in one container (`ghcr.io/hyperstructure/fctl-control`, `deploy/standalone`), on any machine or Kubernetes cluster ([ADR 0026](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0026-the-control-plane-runs-without-cloudflare-on-workerd.md)). `server.mjs` runs them on workerd, Cloudflare's open-source Workers runtime, through Miniflare, with the bindings of `wrangler.toml` and its state (D1 and every Durable Object, as SQLite files) in a data directory. It does what Cloudflare does around the Worker: applies the D1 migrations as it starts, sets `CF-Connecting-IP` (from the connection, or from `X-Forwarded-For` behind trusted proxies) and drops what clients must not set (`CF-*`, `MF-*`, `/cdn-cgi/`), and runs the hourly cron trigger. A lock file keeps a second server off the same data, and the server exits when workerd stops answering, so that Docker or Kubernetes starts it again. `request.cf` is empty there, so sign-ins record no city or country. - `<domain>` — the site Worker from hyperstructure/fctl-site (landing page, docs, `/install`, `/releases`). It reads releases from a KV namespace that `cargo xtask publish` writes (`deploy/releases.toml`); the two share the namespace, not code. - Hosts install with `curl -fsSL https://<domain>/install | sh`, then `fctl login` and `fctl host install` (launchd on macOS, systemd on Linux), and update themselves from then on, from `download_base` in `host.toml` (`https://fctl.sh/releases` unless it says otherwise; [Host updates](https://fctl.sh/docs/architecture.md#host-updates)). Everything instance-specific (domain, GitHub App, Google client, email) is configuration, the same for both; see [self-hosting](https://fctl.sh/docs/self-hosting.md). --- Source: https://fctl.sh/docs/security.md # Security fctl runs code-writing agents with access to your repositories and your Claude subscription. This page says what it protects, how, and what it trusts. ## What it protects | Asset | Where it lives | Who can use it | |---|---|---| | Passwords | argon2id hashes in D1 | nobody can read them back | | Authenticator app secrets (two-factor) | encrypted (AES-256-GCM) in D1 | the control plane, to check codes | | Recovery codes | SHA-256 hashes in D1 | nobody can read them back | | Passkeys | public keys in D1; the private key never leaves the person's device or password manager | the person, with their fingerprint, face or screen lock | | Sessions and tokens | SHA-256 hashes in D1; the secret only with its holder | the holder | | Claude credentials | encrypted (AES-256-GCM) in D1, or, for tokens people keep on their runners, only in a file on those runners (mode 0600) | sessions their owner starts | | Project secrets | encrypted in D1 | that project's sessions, as environment variables | | Workspace avatars | images in D1 | the workspace's members, people it invited, instance admins | | Attached files | the project's Durable Object | people who can see the session they were sent in; its runner and agent | | Published screenshots | the project's Durable Object, found by the SHA-256 of their links' tokens in D1 | anyone with a link, until it expires | | GitHub App key | a Worker secret (outside Cloudflare, the server's environment) | the control plane, to mint short-lived tokens | | GitHub webhook secret | a Worker secret (outside Cloudflare, the server's environment) | the control plane, to check that deliveries come from GitHub | | Linear app's client secret and webhook secret | Worker secrets (outside Cloudflare, the server's environment) | the control plane, to install fctl's app in Linear, renew its tokens and check that deliveries come from Linear | | Linear tokens (fctl's app in each Linear workspace) | encrypted (AES-256-GCM) in D1 | the control plane, for the projects of the fctl workspaces connected to that Linear workspace; never sandboxes | | Web Push (VAPID) key | encrypted in D1 | the control plane, to sign notifications | | Master key | a Worker secret (outside Cloudflare, the server's environment, or a file it made in its data directory) | the control plane | Encrypted values are bound to their row (the AES-GCM associated data names the owner and purpose), so a value copied between rows does not decrypt. ## Access - Accounts exist only for allowlisted emails or domains. Each of an account's addresses was invited (or admitted) on its own, and any of them signs in to the same account, with the same factors. Removing an allowlist entry disconnects the addresses it admitted from their accounts, which leave the workspaces they are in as them, and disables the accounts none of whose addresses is admitted any more, revoking their tokens. Revoking an invitation takes back the entry it added, and demoting or removing an admin turns the entries that admitted their addresses as an admin into a member's, so that a Google sign-up, which needs no invitation, can't bring them back as one. - **Workspaces** keep their projects, sessions, files and secrets to their members: people outside one are told its projects and sessions don't exist, whatever they started there or was shared with them, and instance admins see inside only the ones they are members of. Starting a session makes someone its owner only while they may start sessions in its project: one made a viewer of it, or taken out of it, can't go on messaging its agent, resuming it or sharing it. On an instance that sends email, the password reset and invitation links admins make go to the addresses alone, never to the admin: a reset link signs in as its person, and an invitation's joins the workspaces that invited its address. Without email, admins hand those links over, so whoever runs such an instance can sign in as its people. A workspace sees only the address each member joined as. Only its admins create its projects, so its members' agents reach the repositories of the projects the admins made and opened to them, not every one its GitHub accounts cover. - Browser sessions are `__Host-` cookies (`Secure`, `HttpOnly`, `SameSite=Lax`). An instance served over plain http at an address other than localhost, which only [a control plane of your own](https://fctl.sh/docs/security.md#the-control-plane-without-cloudflare) can be, sets them without the prefix and `Secure`, which browsers refuse there. Requests that change state must come from the app's own origin (`Origin` / `Sec-Fetch-Site`) and carry JSON; WebSocket upgrades are origin-checked too. - **Two-factor sign-in.** With an authenticator app set up (Settings, or `fctl account two-factor enable`), a sign-in with the password, and with Google when you or the admins choose that, takes a code from the app (TOTP) or one of ten recovery codes. Each code works once, and five wrong ones in a row make codes from the app wait a minute, doubling with each further one up to an hour; recovery codes still work meanwhile, and you get an email, since whoever it was knew your password. A reset link's new password takes effect only once a code checks out, so the link alone signs nobody in. Replacing the app, turning it off and getting new recovery codes take a current code; setting up an app takes the password unless the browser signed in within 15 minutes (with a CLI or access token, always), and on an account without a password, that sign-in. Admins can require it: accounts that sign in with a password, and with Google sign-ins asking too every account, then can do nothing until they set it up, in the browser or with their CLI and access tokens. An attacker who has the password of an account that hasn't set it up yet could set it up first; the email about it (when email is set up) and the audit log show that, and admins reset two-factor for people who lost their phone (the primary instance owner for admins; the owner's own takes a recovery code). A workspace's admins can require it of its members in the same way: a member without a second factor can't open the workspace until they set one up, and their sign-ins ask for it. - **Passkeys** sign in without a password or code: the device checks it's the person (fingerprint, face, screen lock or PIN), and the signature works only for this instance's host name, so a fake site can't use it. fctl keeps the public key, each challenge works once for ten minutes, and a counter that goes back (a copied passkey's would) refuses the sign-in. Adding one takes the password unless the browser signed in within 15 minutes, so a stolen session can't add a way in, and emails the person. Where two-factor is required, a passkey also counts as the second factor. - **Sensitive actions take a passkey or a code**, where the primary instance owner turns that on (off by default): inviting and promoting admins, handing over the primary owner's and workspaces' primary admin roles, turning two-factor sign-in or this off, resetting someone's two-factor sign-in, removing a passkey, adding or registering a runner for a workspace and replacing its token, new access tokens and CLI sign-ins, exporting your data, deleting accounts, projects and workspaces, and, without email, making someone a password reset link. So a stolen browser session, CLI or access token can't do them alone. A passkey (which must verify the person), a code from the authenticator app or a recovery code confirms it, for 15 minutes, for the browser, CLI or token that confirmed; a sign-in with a passkey or a second factor counts. People with neither a passkey nor an authenticator app aren't asked, and only the primary instance owner turns it on or off, which takes confirming it too. - The CLI signs in with a device flow (you approve a code in the browser) and gets a revocable token; Settings lists every device and token. - **Access tokens** for scripts, CI and bots can be limited to reading, running sessions or everything their person may, to some workspaces and projects, and in what their sessions' agents may do on GitHub. A limited token reaches projects and sessions alone: one rule in the router refuses it the account, workspaces' people and settings, Claude connections, other tokens and administration before any handler runs, so a token that leaks from CI can't make itself another, and its roles in its projects are capped. It can't message or resume a session whose agent started able to do more on GitHub than it allows, and only a token that runs sessions may hold its agents lower: one that does everything could change the settings that steer them. Make them expire, and revoke one that may have leaked in Settings or with `fctl tokens revoke`. - Sign-in, signup and reset endpoints are rate limited per address and per account; unknown emails take as long to fail as known ones. Signing up without an invitation, asking for a password reset or for early access, and inviting an address to a workspace answer the same whether the address has an account or can sign in, so that none of them tells who uses the instance (instance admins, who see that anyway, excepted). - The web app ships a strict Content-Security-Policy (`script-src 'self'`, no inline scripts), `frame-ancestors 'none'`, `Cross-Origin-Opener-Policy: same-origin`, HSTS, and renders agent output as text (Markdown is parsed into elements, never injected as HTML). Links in what agents and other people write open web pages in a new tab, and never the app's sign-in pages or API, however they name them (`textLink`). - Notifications go through each browser's push service (Google's for Chrome, Mozilla's for Firefox, Apple's for Safari, Microsoft's for Edge), encrypted for that browser alone (RFC 8291): the service sees when one goes and how big it is, not what it says. They name the session and quote the start of the agent's last message, and show like any notification, on a lock screen too; people choose what notifies them in Settings. The control plane only sends to `https://` addresses of named hosts on port 443, follows no redirects from them, and sends to at most 20 browsers per person. - File uploads are `application/octet-stream` bodies, which browsers cannot send cross-site without asking first, and pass the same origin check. Attached files are served with `nosniff` and a sandboxing Content-Security-Policy; only PNG, JPEG, GIF and WebP display in place, everything else downloads, and only those and the videos the app plays keep their type, the rest being `application/octet-stream`, so an uploaded HTML, SVG or JavaScript file never runs as part of the app. - GitHub's webhook (`POST /api/v1/github/webhook`) takes only deliveries signed with the webhook's secret (HMAC-SHA256, checked in constant time), and none without the secret. It takes from them only which pull requests or commits changed, then asks GitHub itself what became of the pull requests sessions list, so a replayed delivery changes nothing. - Linear's webhook (`POST /api/v1/linear/webhook`) takes only deliveries signed with the webhook's secret (HMAC-SHA256, checked in constant time) whose `webhookTimestamp` is within a minute of the control plane's clock, up to 5 MB, and none without the secret. A delegation or mention there starts a session only as the fctl account that linked the Linear account it came from, in a project that takes the issue's team, of an fctl workspace connected to that Linear workspace, and only while that account may start sessions in the project, so nobody in Linear puts a ticket on someone else's session or subscription. A delivery for a Linear agent session fctl has seen starts nothing new; before its session starts, only the person who made it can start it again or choose its project, and once it has, replies reach its fctl session only from linked accounts that may message it. People link a Linear account with a Linear sign-in of their own, whose token the control plane uses once to read who they are and then revokes, so an address in Linear proves nothing on its own; a sign-in as fctl's app links nothing. - Screenshots agents publish for pull requests are public to anyone who has a link, which is how GitHub's image proxy fetches them. A link holds 256 random bits, of which D1 keeps only the SHA-256. The control plane publishes only PNG, JPEG, GIF and WebP images, as their bytes say, up to 5 MB, and serves them with `nosniff`, a sandboxing Content-Security-Policy and `noindex`. Links stop working when they expire (30 days unless the project chooses 24 hours to 60 days), when people who manage the session delete them, and while the instance's admins or the project's maintainers turn publishing off; it is off for both until they turn it on. Anyone a link is shared with, or who can read the pull request, can pass it on. ## Sandboxes Each session runs in its own Linux VM (Apple container or Lima) or container (Docker). The agent runs Claude Code with permission prompts off, because the sandbox is the boundary: it can do anything inside its VM, and nothing outside it except through these channels: - **Lima VMs** get none of the mounts of Lima's templates, which give each VM the home directory of whoever runs the host (read only) and a `/tmp/lima` every VM writes to, and none of their port forwards, which put a VM's services on the host's loopback, where other VMs reach them. A VM still reaches the host's own loopback services (`host.lima.internal`), as Docker Desktop's containers do theirs. Lima hosts of fctl 0.14.1 and earlier cloned VMs that could read that home directory, with the host's runner token and the CLI's credentials. On such a host, remove its runner and install the host again (`fctl host uninstall`, `fctl host install`), sign the CLI in again (`fctl login`) and revoke its old tokens in Settings, and replace any other keys kept in that home directory. - **Network.** Sandboxes have outbound network access (to install dependencies and reach GitHub and Claude). Do not run hosts on networks where an outbound connection from an untrusted process is a problem. Docker sandboxes join a network of their own, `fctl-sandboxes`, on which they reach the internet but not each other, so a session can't reach what another session on the host serves, which may be another workspace's; a Docker that can't make the network leaves them on its default one, and the host says so in its log. With Docker Desktop and OrbStack, containers still reach the host's loopback (`host.docker.internal`). Apple's `container` VMs share its network: on a runner that serves more than one workspace, keep to Docker where that matters. - **GitHub.** Installation tokens for the project's repositories and no others, within the GitHub accounts and repositories its workspace may use, valid for an hour and refreshed on demand ([self-hosting](https://fctl.sh/docs/self-hosting.md#what-sessions-get) has the details): - one for the primary repository and the others listed as read and write, with what the session's agent may do on GitHub: write contents, pull requests, issues and workflows for one that pushes; write pull requests and issues, and read contents, for one that comments; none for one that only reads. git and `gh` use it. - one that can only read (contents, pull requests, issues and CI results) the repositories listed as read only, or every repository of the account the GitHub App can reach when the project grants that (`owner/*`); for an agent that only reads, the others too. git uses it for those repositories. An agent that pushes can push to, open pull requests in, and change the workflows of every repository it can write to, so list as writable only the ones it needs. Sessions that don't need to push, such as review agents and planners, can comment or only read (`fctl run --github`), and a project can hold its agents to that (`fctl projects set --github`): their tokens can't push, whatever they read asks of them. An agent that comments can still close, edit and approve pull requests and issues. GitHub lets only tokens that can push resolve review threads, so the control plane resolves them for such an agent with a token it keeps to itself, for that one repository, after checking that the session may write there. Reading a whole account includes its private repositories, as far as the workspace may use them. Running sessions lose a repository removed from the project, or an account taken from its workspace, when their tokens are next refreshed, within the hour. A session's tokens are minted for the runner the session runs on only: a runner serves the workspaces its owner added it to, and gets no token for a session in another workspace. The project's ticket tracker narrows the issues permission ([Architecture](https://fctl.sh/docs/architecture.md#ticket-trackers)): tokens carry it as above where the project tracks its work in the GitHub issues of every repository it writes to, which is the default, and nowhere where it tracks it in Linear or nowhere. Where it is one repository's issues, only a write token for that repository alone carries it, since a token has one set of permissions for all its repositories. Public repositories' issues stay readable to anyone, so there the agent's instructions keep it away from them. A session that a maintainer of the project, who may change its tracker, starts with `fctl run --github-issues` gets the issues permission as for the GitHub issues of every repository it writes to, whatever the tracker says, for as long as it runs, whoever messages or resumes it. - **Ticket trackers.** `fctl ticket` reaches the project's tracker through fctl-agent and the control plane, which holds the tracker's access: a Linear token reaches every team of its Linear workspace as fctl's app, so it never enters a sandbox, and for one repository's GitHub issues the control plane mints a token for that repository's issues alone for each call and revokes it after. Each call is kept to the project's choice, a Linear team (and project) or a repository, and a ticket outside it answers as one that doesn't exist, as do the parents, sub-tickets and search results outside it. Review agents reach no tracker. In an hour, a session's agent comments at most 30 times, files at most 20 tickets, changes at most 60 and reads at most 300 times, which also keeps one workspace's agents from using up the API of a Linear installation others share. What tickets and their comments say comes from whoever wrote them, inside the team or not: agents are told to take it as what the work is about, not as instructions, and do what the person who delegated it asks. Which accounts a workspace may use is its own admins' decision, and only within what they may touch on GitHub. A workspace's admin connects an account by signing in to GitHub with the instance's app, and the workspace gets what GitHub says that person may give: every repository of their own account, or of an organization whose every repository the app reaches and they administer all of; otherwise the repositories they administer. So no workspace reaches another account's repositories through the shared app, and an instance admin — who holds the app's key — connects nothing into a workspace, though they can take an account away. Installing the app on an account is necessary but not sufficient: it lets the instance mint tokens there, but no workspace uses it until one of its admins connects it with a GitHub sign-in ([ADR 0029](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0029-workspaces-connect-github-accounts-with-a-github-sign-in.md)). A connection is to the account, by GitHub's id, not its name: when a connected account is renamed or deleted and someone else takes its name, the workspace's sessions get no tokens there, and their pull requests no news from it, until its admins connect the account again. - **Claude.** A short-lived access token for the session's owner. Refresh tokens never enter a sandbox: when the access token expires, Claude asks the control plane for a new one through its host. A session gets the token only while the person it runs for is an active member of its workspace and may still run it: start sessions in its project, or message it through a share. Once they leave the workspace, are removed or disabled, lose their role in the project or the share, or the workspace's two-factor requirement closes it to them, the sessions that run on their subscription stop, get no more Claude or GitHub tokens, and a message to one doesn't start it on their subscription again (nor on their runners, with the project's secrets); resuming it runs it on the resumer's. The token is in the sandbox, where the agent can read it, so whoever can message a session's agent (the project's editors, and people it is shared with to message) can have it hand over the token of the person the session runs as, as they can the session's GitHub tokens and the project's secrets: a full Claude login's token lasts hours, one from `claude setup-token` a year. Run sessions on a token from `claude setup-token` only where you would trust those people with it. Hosts replace every Claude token they know of with `[Claude token, redacted by fctl]` in what agents send to the control plane, so a token an agent prints by mistake (`printenv`), or repeats in its reply, stays out of the transcript and of what viewers see live; one it is asked to send elsewhere, or to write in another form (spaced out, encoded), doesn't. - **claude.ai connectors.** A session on a full Claude login gets the connectors its owner has connected on claude.ai (mail, files, docs, chat), and the agent can use them with that person's access without asking, also when a repository or a web page tells it to. Long-lived tokens from `claude setup-token` don't reach connectors. The **claude.ai connectors** project setting turns them off for a project's sessions, and a repository's Claude settings can't turn them back on. New projects start with it off, in the app, from the CLI and through the API (turn it on in the form, with `fctl projects create --connectors`, or in Settings); projects from before the setting keep their connectors until a maintainer turns them off. - **fctl.** No fctl credentials enter a sandbox. Through fctl-agent's session socket, anything the agent runs can rename its own session and describe it (`fctl edit`); in projects that publish screenshots, publish images at public links (`fctl publish`); and in projects that keep a memory or ask for retrospectives, read the project's notes, add notes, mark them stale or duplicates of other notes, and record, list and update retrospectives of its session (`fctl memory`, `fctl retro`); and where the agent may comment on GitHub, resolve review threads of its repositories' pull requests (`fctl resolve`); and nothing else. The control plane checks the values and the settings again. Turn publishing on only where anything the agents see may be shown to anyone with a link: text in a repository or on a web page can ask an agent to publish a screenshot, as it can ask it to send what it sees elsewhere. - **Project memory** is text that the agent of every later session in the project starts with, and anything an agent runs can add to it, so text in a repository or on a web page that steers one agent can leave a note for the agents after it, past any review. `~/AGENTS.md` presents the notes as notes that don't override the person's request or fctl's rules; each note names the session that wrote it, the transcript shows it as it is kept, and the project's editors delete the ones they don't want, which the workspace's audit log records. Turn it off (`fctl projects set --project-memory off`) in projects whose agents read text from people you don't trust. - **Schedules** start sessions that nobody watches start, on their owner's subscription: whoever may set one up may start sessions anyway, and a change by anyone else runs it on theirs. Runs that other tools ask for carry the tool's text (`context`), which anyone may have written, such as an issue's author: the agent gets it after the task, marked as information and not instructions, which makes it harder, not impossible, for that text to steer it. Give such a schedule the least GitHub access its task needs and a token that runs sessions in its project alone, and pause it to stop what tools start at once. - **Review agents** run on their operator's Claude subscription, with their Claude access token in the sandbox like any session's, and read what pull requests say, which can steer them as any text can. Their GitHub token only reads, fctl posts what they find, and they get no secrets, setup script, memory or connectors. A pull request starts a review by itself only when its author may write to the repository, or one of fctl's agents or of the GitHub Apps the project's owners chose (Dependabot until they choose) opened it from a branch of the repository; anyone else's waits until someone in the project looks at it and asks for its review, since its text could otherwise have the agent hand the operator's token over ([ADR 0047](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0047-review-agents-review-outsiders-pull-requests-when-asked.md)). An app's pull requests can carry text from outside all the same, such as the release notes Dependabot and Renovate quote, and installing the dependencies they update runs their code, so their agent installs and runs nothing of them: add only apps whose pull requests you'd have it read unasked ([ADR 0054](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0054-review-agents-review-dependabots-pull-requests-by-themselves.md), [ADR 0057](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0057-projects-choose-which-apps-pull-requests-start-reviews.md)). - **Project secrets** are exported to the agent's environment. Treat them as readable by anything the agent runs. - **The image and the setup script.** Whoever sets a project's image or setup script decides what runs in its sandboxes, beside the credentials of the people who start its sessions: their Claude access token and the session's GitHub tokens. A full Claude login's access token lasts hours; a token from `claude setup-token` is valid for a year. Maintainers and owners set both, and for an image built from the repository, so does whoever can push to its default branch. Hosts' owners decide which images their machines run and which repositories they build images from ([below](https://fctl.sh/docs/security.md#hosts)). - **Package caches.** In a project that shares them (`fctl projects set --package-caches on`), its sessions on a runner share what their package managers download, in a Docker volume or, with Apple's `container`, a directory on the Mac that each sandbox mounts. Anything a session runs can change what they hold, and the project's later sessions on that runner build with it: package managers check a download against a lockfile's hashes as they fetch it, but most don't check again what is in their cache already, such as the sources Cargo unpacked. Projects never share caches, and review sessions, whose pull request could steer their agent, get none. Leave them off in projects whose agents run code from people you don't trust. - **Attached files** are saved in the sandbox under `~/attachments/`, where anything the agent runs can read them, and Claude sees images in the message. The `process` driver has no isolation at all and is for development only. ## Commits Agents' commits are authored by the GitHub App's bot. They carry Claude's `Co-authored-by` trailer unless the project turns off **Credit Claude in commits**. They name the person who started the session only when that person chose a commit email for the address they are in the workspace as (Settings → Email addresses, or `fctl account set --commit-email`) and the project credits people: a trailer then carries their name and that address. Maintainers turn that off with **Credit people in commits** in the project's settings (`fctl projects set --credit-people off`); its sessions then don't receive the address at all. Everyone who can read the repository sees the address in the commits, and people who can see the session see it in the transcript; commits keep it after it is removed or the account deleted. Settings and the API show the address only to its owner, not to admins. fctl never puts sign-in emails in commits. ## Billing fctl is built so agents only ever use Claude **subscriptions**: - `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_BASE_URL`, the Bedrock/Vertex/Foundry switches and any other `ANTHROPIC_*` variables are removed from the sandbox environment, and project secrets cannot set them. - A repository whose `.claude/settings.json` sets `apiKeyHelper` or `ANTHROPIC_*` variables runs without project settings. - The session refuses to start if Claude reports any API key as its credential source. - When Claude reports that a turn draws on paid extra usage, the agent is interrupted and the session pauses until the plan's limit resets. ## Hosts Hosts connect outbound to the control plane over TLS (rustls with the Mozilla root store); they open no ports. A host authenticates with a runner token that its owner can replace, and that its owner and instance admins revoke by disabling or removing the runner: whoever holds a runner's token runs the sessions of the workspaces it serves, so only its owner gets a new one, not an instance admin outside those workspaces, and only while none of those workspaces' two-factor requirement closes it to them: a password alone gets no runner token. Registering a runner for a workspace and replacing a runner's token take confirming it's you, where the instance asks for that, as adding one to a workspace does. Hosts receive a session's credentials only while it runs, and keep nothing secret on disk but their runner token (`~/.config/fctl/host-token`, mode 0600) and, when their owner keeps it there, their owner's Claude token (`~/.config/fctl/claude-token`, mode 0600). **Claude tokens on runners.** Someone who would rather not give fctl their Claude token keeps it on their runners (`fctl host claude set-token`) and connects "On your runners" ([ADR 0050](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0050-claude-tokens-can-stay-on-their-owners-runners.md)). The control plane is never given the token and stores none for them, hosts tell it only whether they keep one and when it was saved, and they take the token out of what sessions send wherever it appears whole: a breach of D1 and the master key, or an operator of the instance, doesn't get it, short of an agent that was made to pass it on (below). The control plane sends that connection's sessions only to those of its owner's runners that keep a token, in every workspace, whatever **Only on my runners** says, and each of those hosts puts in the token it keeps, so no one else's machine ever has it. Without one of those runners online, the sessions wait. What it doesn't change: the agent has the token in its environment, as above, so the people who may direct it can still have it handed over; and the control plane decides what runs on a runner, so one taken over could start a session there to the same end. The token is safe at rest and from other people's machines, not from the sessions that run on it. Removing it from a runner applies to the runs that start after, and doesn't revoke it: it works until it expires, a year after `claude setup-token` made it. A runner takes only the sessions of the workspaces it is added to, and so sees their code, secrets and the tokens of the people who start them: the session's GitHub tokens and the Claude token of the person it runs as. Its owner adds it to a workspace for everyone's sessions there only as one of its admins; a member adds theirs for their own sessions alone, which show the machine nothing they couldn't read in the session, and a demoted admin's runners run only their own from then on. A workspace's admins take any runner out of it. Each member chooses, per workspace, to run their sessions only on runners they registered (**Only on my runners**, `fctl workspaces own-runners on`), so that no one else's machine sees their Claude token or their sessions' work. Once a runner no longer runs a session (taken out of the workspace, its owner left it or was disabled, or the session's person keeps theirs on their own runners), the session stops, and the runner gets no more tokens, messages or files for it, whatever its host does. A Claude sign-in through a host, which passes the full login's long-lived tokens through it, runs on one of the person's own runners when one is online, else on a runner that runs their sessions. **Updates.** Hosts update fctl by themselves to the version the control plane runs, unless `auto_update = false` in `host.toml` (`fctl host auto-update off`) ([ADR 0053](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0053-hosts-update-themselves-to-the-control-planes-version.md)). The release comes from the host's own `download_base`, fctl.sh unless it says otherwise: the control plane names only a version, which must be newer than the host's, and never where to download it, so a control plane taken over can't make its hosts install anything else, or an older fctl. The host checks the archive against its SHA-256 and runs the new fctl before anything changes, as `fctl self update` does. The checksum comes from the same server, though: it catches broken downloads, not a release server taken over. Leaving updates on trusts fctl.sh with what runs on the machine, as installing from it does, without choosing when; with them off, `fctl self update` updates the host when you choose, once you have looked at the release. Turn updates off on machines where that matters. Sessions run in their project's sandbox image or, without one, the host's: by default `ghcr.io/hyperstructure/sandbox`, which the Image workflow of the public [hyperstructure/sandbox](https://github.com/hyperstructure/sandbox) repository builds, so anyone can read what goes into it. A host can use its own build of it instead (`fctl host image build`) or a copy in another registry (`image` in `host.toml`). A project's own image runs only on hosts that allow it, and its build only on hosts that build images from its repository: `allowed_images` and `allowed_builds` in `host.toml`. Without `allowed_images`, a host runs any image a project chooses, as hosts did before it, so set it on machines where that matters. Without `allowed_builds`, it builds none. The control plane schedules sessions by these lists, and the host checks them again before it takes a session. To build, the host downloads the repository at the tip of its default branch with the session's GitHub token, which the build never sees, and runs the build in its container runtime's builder, outside any sandbox ([Sandbox images](https://fctl.sh/docs/sandbox-images.md#which-images-a-host-runs)). Hosts pull images with their own registry logins; fctl stores no registry credentials. ## The control plane without Cloudflare The standalone control plane ([Self-hosting](https://fctl.sh/docs/self-hosting.md#on-your-own-machine-or-cluster)) runs the same Worker, so the rest of this page holds for it too. What Cloudflare does around the Worker, its server (`deploy/standalone`) does: - **Addresses.** Sign-in rate limits and the audit log go by the address in `CF-Connecting-IP`. The server sets it from the connection, or behind `FCTL_TRUST_PROXY` proxies from what they added to `X-Forwarded-For`, and replaces any a client sends. It also drops headers that steer Miniflare (`MF-*`), which would take them from anyone, and answers `/cdn-cgi/`, where Miniflare has pages of its own, with a 404. - **Secrets** are environment variables, or files (`<NAME>_FILE`). Without `FCTL_MASTER_KEY`, the server makes a master key and keeps it in its data directory, next to what it encrypts: a copy of the directory then has both. Set the key from a secret store to keep them apart. - **TLS** is the reverse proxy's in front. Over plain http, anyone on the network in between reads sign-in cookies, CLI and runner tokens, and whatever the apps show; keep such an instance to a network you trust. - **The data directory** holds D1 and every Durable Object as SQLite files: password hashes, encrypted credentials and secrets, transcripts and attached files. Encryption at rest is the disk's. One server at a time uses it (`server.lock`): two would each run the same sessions. - Errors the Worker throws come back as plain 500s, without the stack traces Miniflare shows in development. ## Retention - Deleting a project deletes its sessions with their transcripts (the event logs in their Durable Objects) and the files attached to their messages right away; an hourly job retries any that fail. Uploaded files that no message sent are deleted after a day. - Expired browser sessions and tokens, password resets, sign-ins waiting for a second factor (ten minutes), device codes and invitations are deleted within the hour, and audit log entries after a year, as are passkey challenges (ten minutes). Turning two-factor off deletes the authenticator app's secret and the recovery codes; an admin's reset also deletes passkeys where they are the second factor required. A browser's notification subscription goes with the sign-in that made it (signing out, revoking or expiry), or when its push service says it is gone. A person's notifications wait in their Durable Object for at most a minute and are deleted once sent. - Deleting a workspace deletes its projects, as deleting each would, and its audit log; the instance's audit log records who deleted it. - People delete their own account in Settings (Danger zone) or with `fctl account delete`; admins delete members' accounts, and the primary instance owner admins' too. Both delete the account's addresses, sign-in methods, passkeys and two-factor sign-in, tokens, Claude connections, memberships, shares, follows and notification settings and browsers, and the workspaces only it is in, with their projects and sessions. Sessions and projects it created that others still use stay, credited to "Deleted user": the account remains only as a disabled placeholder, without its name, emails, commit emails or picture. Commits already pushed keep the trailers in them. The primary instance owner hands the role over before they can delete their account. - Settings → Your data (or `fctl account export`) downloads everything the instance keeps about you, as JSON, with the sessions you started in the workspaces open to you: not those of workspaces you left. The notes and schedules you wrote, and the sessions you follow, come from the projects and sessions you still see, since others keep them up to date. Files you attached are listed in the transcripts there; each downloads from its session. - A Linear account people link stays until they unlink it (Settings, or `fctl account linear disconnect`) or delete their fctl account. fctl's tokens in a Linear workspace stay while an fctl workspace is connected to it; once none is, the control plane revokes them at Linear and deletes them (within the hour if Linear doesn't answer), and it forgets them as soon as Linear says the app was uninstalled or refuses to renew them. A delegation waiting for someone to link their account keeps what Linear sent for an hour, and which Linear agent session reached which fctl session goes with the session. - Hosts keep a stopped session's sandbox so it can resume, and remove it once it has been stopped for `keep_stopped_hours` (72 by default; 0 keeps them), or sooner when their disk has less free than `min_free_disk_gb` (20 GiB by default). Resuming after that starts a new sandbox on the session's branch, with everything that was pushed, and a new Claude conversation. - Hosts keep the package caches of projects that share them, with what their sessions downloaded, until no session of the project has started there for `keep_caches_days` (14 by default; 0 keeps them). They empty them sooner once they take more than `max_cache_gb` (10 GiB by default), and when the disk runs low. ## Reporting a vulnerability Report it privately through GitHub: **Security → Report a vulnerability** on [hyperstructure/fctl](https://github.com/hyperstructure/fctl/security), with details and a way to reproduce. Please do not open a public issue. We will acknowledge within three working days and keep you updated until it is fixed. --- Source: https://fctl.sh/docs/development.md # Development Everything runs on one machine, with one command, and needs no Claude subscription, Cloudflare account or GitHub App. ## Setup - Rust via [rustup](https://rustup.rs) — `rust-toolchain.toml` pins the version and adds the `wasm32-unknown-unknown` target. - Node.js 22 or newer (`npm ci` at the root installs the web app and wrangler). The web app depends on [@hyperstructure/design](https://github.com/hyperstructure/design) through git, at a tag, which `npm ci` downloads from GitHub without signing in. - [worker-build](https://crates.io/crates/worker-build), which builds the control plane for wrangler. The first `cargo xtask dev` installs it with `cargo install` if it isn't on your PATH. On Linux that needs pkg-config and OpenSSL's headers: `sudo apt install pkg-config libssl-dev` on Debian and Ubuntu. - Optional, for release builds: [zig](https://ziglang.org) and `cargo install cargo-zigbuild`. ## The local stack ```sh cargo xtask dev ``` This starts: - the control plane under `wrangler dev` at http://127.0.0.1:8787, with local D1 and Durable Objects (state in `crates/fctl-control/.wrangler/`); - the web app under Vite at http://localhost:5173, with hot reload, proxying `/api` to the control plane; - a host using the `process` driver whose agents run **fake-claude**: a scripted stand-in for Claude Code that speaks the same stream-json protocol. The first run creates the primary instance owner (`dev@fctl.test` / `fctl-dev-password`), a Claude credential, a workspace called Development, a runner for it and a project called Playground, and stores a CLI token in `.fctl-dev/token`: ```sh export FCTL_SERVER=http://127.0.0.1:8787 FCTL_TOKEN=$(cat .fctl-dev/token) cargo run -p fctl -- run -p playground "write notes.md" ``` To try a Claude token kept on the runner, keep a made-up one on the dev host and connect it; the dev host's next heartbeat, within 15 seconds, tells the control plane, and sessions on it then run there with that token: ```sh echo sk-ant-oat01-fctl-dev-kept-on-the-runner-0000000000 \ | FCTL_CONFIG_DIR=.fctl-dev/host cargo run -p fctl -- host claude set-token cargo run -p fctl -- claude connect --on-runners ``` More workspaces, and people in them, take a few commands: `cargo run -p fctl -- workspaces create "Side project"`, then `workspaces add-runner dev-host -w side-project` so that the dev runner takes its sessions too, and `admin invite grace@example.com -w side-project`, whose link signs Grace up in another browser. The dev instance has no GitHub App, so projects there have no repository and the GitHub tabs say so. It takes requests for early access, as app.fctl.sh does: signed out, the sign-in page links to `/early-access`, and the requests show under Admin → Early access and in `cargo run -p fctl -- admin early-access`. fake-claude reacts to words in the prompt: `explore` looks around the working directory first, as agents do before they change something (a thought, whose tokens the session page counts as it comes, then commands, searches and a read, two of them at once, with another thought among them), which the session page folds into a line that opens into its steps (`explore <n>` looks around that many times in one line, more than the newest events the session page gets first, which then loads earlier ones until it has a screenful to show), `write <name>` creates and commits a file, `push` pushes the branch (dev projects have no repository, so a bare one in the sandbox's `~/.fake-github` stands in for GitHub), `watch <seconds>` watches the pull request's checks in one tool call that long, as agents wait for CI before they end their turn (meanwhile the session page shows the commit and the push), `rename <title>` names the session with `fctl edit` (the host's own fctl comes first on its agents' PATH), `screenshot` draws a small PNG and publishes it with `fctl publish` (allow that first: `cargo run -p fctl -- admin set --publish-screenshots on`, then `cargo run -p fctl -- projects set --publish-screenshots on`), `remember <text>` adds the rest of its line to the project's memory with `fctl memory add`, `recall` lists the memory with `fctl memory`, a line that starts with `fctl ticket` runs as it is (`fctl ticket view ENG-1`), as agents reach their project's tracker, `printenv` shows the `CLAUDE_CODE_…` variables it got and says the token back, streamed in pieces (the host redacts the Claude token in both), `retro` records a retrospective with `fctl retro` (completed, or `partial` or `blocked` when the message says so; the project's Memory and Retrospectives tabs, and the session page, show them), `revise` updates the session's newest retrospective to completed with `fctl retro list` and `fctl retro update`, as agents do when a later turn carries on with the same work, `slow` streams slowly, `background <seconds>` starts a shell command in the background that ends after that many seconds and takes its result in a turn of its own (the session waits for it, then runs again, as with Claude Code), `delegate` hands a look around the sandbox to a subagent, whose steps the session page shows under its Delegated line as they come (`delegate in the background` runs it in the background: the turn ends, the subagent works on, and a turn of its own takes its report, which starts without a `running` of its own, as with Claude Code), `limit` hits a usage limit that resets 20 seconds later (the session pauses and continues half a minute after the reset; a message that also says `reset`, like the one fctl-agent continues it with, doesn't count), `overage` reports paid usage (the session must pause), `warning` reports plan usage with Claude Code's warning that the weekly limit goes faster than the week passes (`allowed_warning`: the apps go by the share used instead), `refresh` asks the host for a new OAuth token, `effort <level>` (or Claude Code's `/effort <level>`) changes the effort level it works at (`low` … `max`, or `none` as a model that takes none), `context <tokens>` fills its context to that many tokens before it answers (`context 155k`, which the session page shows in amber, nine tenths of the way to where it compacts), `compact` (or Claude Code's `/compact`) compacts the conversation, a review session's task (`Review acme/web#12, … at commit …`) reviews as a review agent does: it reads the pull request's commits and diff and fctl's earlier findings (`fctl review threads`), then posts with `fctl review submit`, marking earlier findings still open fixed, and without any, finding an issue on the first line the pull request adds (the dev instance has no GitHub App, so no review starts there by itself), fctl's message about a review of the agent's own pull request (`fctl's review agent reviewed commit …`) gets a look at the branch's commits and an answer that leaves each finding as it is, without calling GitHub (no other word in it counts), so does fctl's message about what happened to its pull request on GitHub (`On GitHub, …`: a comment, failed checks, conflicts, a merge), with a line for each part (the dev instance has no GitHub App, so none comes there by itself), and `crash` exits mid-turn. Anything else gets a short answer with a tool call. It answers `get_settings` with its model and effort level (`--effort`, else `medium`), which the session page and `fctl show` show. Its context grows with each call, from the 18K tokens of its system prompt and tools; like Claude Code with a 200K window, it compacts the conversation on its own once its context reaches 167K tokens, and it answers `get_context_usage` with that window and where it compacts, which the Work section of the session page and `fctl show` show with the context. A message sent during a turn waits for it and gets a turn of its own; each `result` says how many messages still wait (`queued_turn_count`), as Claude Code's does, so the session stays running until fake-claude has answered the last one. Like Claude Code, it starts each turn with `system/init`, and reports its state (`session_state_changed`) when `CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS` is set, as fctl-agent sets it: `running` as a turn starts and `idle` once it has ended, but from a turn that starts a subagent in the background, `running` until the turn that takes its report has ended, as Claude Code 2.1.308 does. A message with attached files also gets a sentence about them: how many images it holds, and whether each listed file is on disk. Turns other than `limit` and `crash` report plan usage as Claude Code does (`unifiedWindows`), with five-hour and weekly windows that follow the clock, so Settings → Claude, the Agent Floor and `fctl claude status` show it. Each of its API calls reports tokens as Claude Code's do, its input as it starts and with its assistant messages, and its output in the `message_delta` that ends it, so the session page's Work counters move during a turn. Its `system/init` message lists a claude.ai connector, `claude.ai Fake Docs`, unless `ENABLE_CLAUDEAI_MCP_SERVERS` is `false`, so sessions in a project with claude.ai connectors turned off show none (`fctl logs --json`). Its commits end as Claude Code's do: with Claude's trailer, or with the `attribution.commit` fctl-agent writes into Claude's settings. To see yours, set a commit email (`cargo run -p fctl -- account set --commit-email 1+dev@users.noreply.github.com`); to see Claude's go, `cargo run -p fctl -- projects set --credit-claude off`. The dev instance has no Linear app either. To try Linear's side, point the control plane at a stand-in for Linear in `crates/fctl-control/.dev.vars` (read only as wrangler starts): `LINEAR_CLIENT_ID`, `LINEAR_CLIENT_SECRET` and `LINEAR_WEBHOOK_SECRET` with made-up values, and `LINEAR_API_URL` and `LINEAR_AUTHORIZE_URL` with the stand-in's address. It must answer `POST /oauth/token` and `/oauth/revoke`, the GraphQL queries and mutations in `crates/fctl-domain/src/linear.rs` at `POST /graphql`, and its authorize page by sending the browser back to `/api/v1/linear/installed` with a code and the `state` it got. Connect it on the workspace's Linear tab, choose a team in Playground's Tickets tab and link your account in Settings; then deliveries it signs with the webhook secret (`Linear-Signature`, the body's HMAC-SHA256 in hex, with a `webhookTimestamp` of now) at `/api/v1/linear/webhook` start and message sessions as Linear's do, and what fctl posts back arrives as its GraphQL mutations. Notifications work locally too: `http://localhost:5173` is a secure context, so turn them on in Settings → Notifications, follow a session and give it a task. The dev control plane sends them through your browser's real push service, so it needs the internet. A finished turn notifies you unless the session's tab is visible; `limit` pauses a session. `--no-host` skips the host; `--no-vite` serves the built web app from wrangler instead; `--smoke` runs one session and stops ([Checks](https://fctl.sh/docs/development.md#checks)). Delete `.fctl-dev/` and `crates/fctl-control/.wrangler/` to start over. The hourly housekeeping job (`crates/fctl-control/src/housekeeping.rs`: purging deleted sessions, deleting expired sign-in records, indexing sessions for search that the index lacks) runs on a cron trigger. To run it locally: ```sh curl "http://127.0.0.1:8787/cdn-cgi/handler/scheduled?cron=17+*+*+*+*" ``` A session is searchable (⌘K, `fctl search`) five seconds after its messages and replies come, when its alarm puts them in its workspace's SearchObject. The job indexes again, from their first events, the sessions D1 says the index lacks something of, as from before search; to see it do so, say that of them all, then run it: ```sh cd crates/fctl-control && npx wrangler d1 execute DB --local --command "UPDATE sessions SET search_indexed = 0" ``` Schedules (`crates/fctl-control/src/schedules.rs`) start their sessions on ScheduleObject's alarm, which rings under `wrangler dev` too: one that runs in a minute shows a run start on time, as fake-claude takes its task. ```sh cargo run -p fctl -- schedules add "Check" -p playground --at 1m -m "write check.md" cargo run -p fctl -- schedules add "Busy" -p playground --cron "*/15 * * * *" -m "watch 90" cargo run -p fctl -- schedules run Busy -p playground # its task now, with --context for a tool's text cargo run -p fctl -- schedules runs Busy -p playground # what each run started, or why it started none ``` To make a schedule due at once, set its next run in the local D1 and wake the alarm with the hourly job (`npx wrangler d1 execute DB --local --command "UPDATE schedules SET next_run_at = 0 WHERE name = 'Busy'"` in `crates/fctl-control`, then the `curl` above): a run while its last session still works (`watch 90`) is skipped. A row in `claude_usage` with a `utilization` of 1 and a `resets_at` ahead puts the dev subscription's plan at its limit, which skips the runs of a schedule until then. The dev `.fctl-dev/token` is an access token, so its runs count as a tool's. ## The standalone control plane Outside Cloudflare, the control plane runs as `deploy/standalone/server.mjs` runs it: the Worker and the web app built for production, on workerd through Miniflare, as `wrangler dev` runs them, with what Cloudflare does around the Worker done by the server: migrations, the client's address, the hourly job ([Self-hosting](https://fctl.sh/docs/self-hosting.md#on-your-own-machine-or-cluster), [ADR 0026](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0026-the-control-plane-runs-without-cloudflare-on-workerd.md)). Its container image is `deploy/standalone/Dockerfile`. To run it from your checkout, without Docker: ```sh FCTL_SETUP_TOKEN=dev-setup-token cargo xtask serve # http://localhost:8787, state in .fctl-serve/ cargo xtask dev --smoke --server http://localhost:8787 # a host and a session through it ``` `--server` points `cargo xtask dev` at a control plane that already runs, instead of starting `wrangler dev`: it bootstraps the dev owner there with the dev setup token, and runs the host (and with `--smoke`, the session) against it. Give it the server's `FCTL_PUBLIC_URL`, the origin the server accepts requests from. Its state goes in `.fctl-dev/server/`. Delete `.fctl-serve/` to start the server over. The image runs the same session with Docker, as the Standalone image workflow does ([Checks](https://fctl.sh/docs/development.md#checks)): ```sh docker build -f deploy/standalone/Dockerfile -t fctl-control . docker run --rm -d --name fctl -p 127.0.0.1:8787:8787 \ -e FCTL_PUBLIC_URL=http://127.0.0.1:8787 -e FCTL_SETUP_TOKEN=dev-setup-token fctl-control cargo xtask dev --smoke --server http://127.0.0.1:8787 docker stop fctl ``` ## Checks ```sh cargo xtask check # what CI runs cargo xtask check --quick # without the production builds cargo xtask check test # one part: lint, test or web cargo xtask dev --smoke # and CI's other check: a session through the local stack ``` That is `cargo fmt --check`, a check that `web/src/gen` matches the Rust types, Biome and TypeScript for the web app, Biome for the standalone server, Clippy (native and wasm) with warnings as errors, `cargo test`, the web app's Vitest suite, the standalone server's tests (`node --test`), and a production build of the web app. It comes in three parts: `lint` (formatting, the generated types and Clippy), `test` (`cargo test`) and `web` (the rest), and runs all three, in the order that fails fastest, when you name none. CI runs each part in a job of its own, at once, and its `cargo xtask check` check passes once all three have; a part that fails there fails the same way locally with `cargo xtask check <part>`. `cargo xtask dev --smoke` starts the local stack as `cargo xtask dev` does and runs `fctl run -p playground --model haiku` with a task that has fake-claude commit `notes.md`, push it, watch its checks for 12 seconds, keep a note in the project's memory, record a partial retrospective and update it, and leave a command and a subagent working in the background. It waits until the session has shown the commit pushed before the turn ended, the transcript has shown the note and the retrospective recorded and updated, the turn has ended, the session has waited for the command and the subagent and has run again while the agent took each one's result in a turn of its own, checks that the project's memory has the note and the session its one retrospective, completed and updated, then stops the session, checks the model and effort level it reports and that the project remembers the model it asked for, and stops everything else. It fails when any step does. CI runs it from a fresh checkout, so its migrations, Worker, bootstrap, host and agent all run before a change can deploy. Locally it uses your `.fctl-dev/` state and ports 8787 and 5173: stop `cargo xtask dev` first. `cargo xtask dev`, with `--smoke` or without, builds the CLI, the host and the agent, the Worker (`worker-build --dev`, in `target/worker`, so that the two cargo builds don't wait for each other), and the web app and the local D1's migrations at once; the lines of the last two come after `worker │` and `web │`. CI caches the dependencies, not the workspace's own crates ([Swatinem/rust-cache](https://github.com/Swatinem/rust-cache), set up by `.github/actions/rust` with the toolchain), and builds without debug info. Two builds of the workspace are cached by the files they are built from instead: xtask's binary (`.github/actions/xtask`), which the jobs run as `xtask check lint` and the like, and on `main` the Worker's release build, which runs beside the checks for the deploy. Only `main` saves caches; pull requests use `main`'s. The Standalone image workflow (`.github/workflows/standalone-image.yml`) builds the standalone control plane's image, starts it and runs `cargo xtask dev --smoke --server` against it, so the container, its migrations and its proxy run a session too. The build takes several minutes, so it runs on the pull requests that change the image, its server or the bindings it reads (`deploy/standalone`, `.dockerignore`, `crates/fctl-control/wrangler.toml`), and for each release before it publishes the image; other pull requests don't run it. To check the image of another change, run the same session through it yourself ([The standalone control plane](https://fctl.sh/docs/development.md#the-standalone-control-plane)). Pull requests that change the agent skills in `.claude/skills` also run the Skills workflow, which validates them all in one job against the [Agent Skills specification](https://agentskills.io/specification), with [agent-skills-validator](https://github.com/coolapso/agent-skills-validator). Dependabot opens security updates, and no version updates (`.github/dependabot.yml`). It opens them from the root, where `package-lock.json` and `Cargo.lock` are, and titles them as Conventional Commits (`build(deps): bump …`, `ci(deps): bump …` for the workflows' actions), so they pass these checks like any other pull request. A title that a long package name and its versions take past 72 characters (`build(deps): bump @cloudflare/workerd-linux-64 from …`) fails the Conventional Commits check: shorten it on the pull request, which reruns the check. GitHub also raises an alert for `web/package.json`, `deploy/standalone/package.json` and the crates' own `Cargo.toml` files, but Dependabot would update those without the lockfile, so their directories open none. ## Repository map | Path | What | |---|---| | `crates/fctl-proto` | Wire types for every protocol; TypeScript is generated from them | | `crates/fctl-domain` | Pure logic: permissions, validation, tokens, naming, scheduling, event shaping | | `crates/fctl-control` | The control plane Worker (Rust → wasm): API, auth, Durable Objects, GitHub | | `crates/fctl-client` | HTTP and WebSocket client used by the CLI and hosts | | `crates/fctl-host` | The host daemon: control connection, sessions, sandbox drivers, images | | `crates/fctl-agent` | The supervisor inside each sandbox: runs Claude, journals events, prepares git | | `crates/fctl` | The `fctl` binary: CLI plus `fctl host` | | `crates/fake-claude` | The scripted Claude stand-in | | `xtask` | `cargo xtask`: dev, check, gen, serve, deploy, secrets, dist, publish | | `web` | The web app (React, TanStack Router and Query, Tailwind, WebGL) | | `deploy/instances` | Instance descriptions for `cargo xtask deploy` | | `deploy/standalone` | The control plane without Cloudflare: its server, container image, Compose and Kubernetes examples | | `docs` | These docs; `docs/adr` records decisions, and `docs/llms.txt` sums fctl up for agents | ## Changing a protocol 1. Change the types in `crates/fctl-proto` — add fields as `Option` (with `#[serde(default)]` where needed), never repurpose a `type` tag. 2. `cargo xtask gen` regenerates `web/src/gen`. 3. Keep older peers working: hosts and agents can lag the control plane by a version, and the web app talks to whatever is deployed. ## Working on the control plane - It compiles to `wasm32-unknown-unknown` and runs on workers-rs. No `unwrap`, `expect` or panics (Clippy denies them): a panic kills the request, and in a Durable Object it can poison the instance. - Durable Object SQL must not fail on a schema that is already migrated: create tables with `IF NOT EXISTS` and add columns with `add_column`, which checks first. - D1 migrations are in `crates/fctl-control/migrations`; `cargo xtask dev` applies them locally (in CI, to a fresh database), `cargo xtask deploy` before deploying, and the standalone server as it starts. Never edit a migration that has shipped. - It runs outside Cloudflare too, on workerd through Miniflare (`deploy/standalone`), with the bindings in `wrangler.toml`: a binding added there and in `xtask/src/deploy.rs` reaches both. Workers APIs that Miniflare lacks don't work there. Self-hosted instances keep their state where Miniflare does (`state/v3` in their data directory): when you upgrade wrangler, check that its Miniflare still reads it there. ## Working on the web app ```sh cd web npm run dev # with the control plane from `cargo xtask dev --no-vite`, or any instance via FCTL_API npm run check # Biome and TypeScript npm test ``` Design tokens, fonts, the WebGL scenes, the mark and the app icons come from [@hyperstructure/design](https://github.com/hyperstructure/design), pinned to a tag in `web/package.json` (it has a playground showing all of them). Use the semantic colors (`canvas`, `surface`, `ink`, `accent`, `highlight`, `pink`, `blue`, `danger`…) rather than raw values; `danger` is the one red, for destructive actions, errors and failures only. Hovered rows and buttons fill with `hover` (`hover:bg-hover`, fainter on large rows), not `raised`, which is white in the light theme, as `surface` is. The scenes render dithered to the brand palette; they pause when hidden and render one still frame for people who prefer reduced motion. An address the app doesn't know shows `src/routes/not-found.tsx`, and a request that fails before a page can show (the control plane's 5xx, no connection) or a page that crashes shows `src/routes/error.tsx`, the router's error page. Both are the design package's stream, with the code as glyphs people can throw (`src/components/stream-screen.tsx`); on the error page the power goes out and "Try again" turns it back on, asking `/api/v1/health` before it loads the page again. To see it, block `/api/v1/auth/me` in DevTools (Network → Block request URL) and reload. fctl.sh has its own 404 and 5xx pages, in hyperstructure/fctl-site. People install the app on their phones and computers. `public/manifest.webmanifest` describes it, the `brand-assets` plugin in `vite.config.ts` serves the favicon and icons from the design package, and `public/sw.js` shows `public/offline.html` when a page cannot load offline, and shows notifications. The service worker registers by itself only in production builds (turning notifications on registers it anywhere): to try installing, run `cargo xtask dev --no-vite` and open http://127.0.0.1:8787 (Chrome's DevTools → Application shows the manifest and the worker). In the installed app on a phone, the page reaches under the notch and the home indicator, so anything fixed to an edge of the screen keeps clear of them with `env(safe-area-inset-*)`, as the message box does. On a phone the app keeps the screen's size. Zooming is off: the viewport tag in `index.html` asks for none (which also keeps Safari from zooming into a text field), and `src/lib/zoom.ts` cancels the pinches Safari on iOS allows anyway. The image viewer zooms its picture by itself instead: `src/components/ui/lightbox.tsx` follows the fingers, and `Zoom` in `src/lib/zoom.ts` works out where they take the picture. No page is wider than the screen: what can be wider than a phone, like the Agent Floor's workspace chips or a page's tabs, scrolls in a row of its own (`overflow-x-auto`), long names, addresses and links wrap (`[overflow-wrap:anywhere]`) or truncate, and the signed-in layout clips whatever still sticks out. A card whose buttons go under its text when it is narrow, like Home's invitations, goes by its own width rather than the screen's, with a container query (`@container` on the card, `@xl:` and `@max-xl:` inside): from 768 px the sidebar takes 244 px of the screen, so a card is narrower at 768 px than at 767. When you change a page, look at it 320 to 430 px wide, with long names in its data, and beside the sidebar at 768 px. ## Other repositories | Repository | What | How it connects | |---|---|---| | [hyperstructure/design](https://github.com/hyperstructure/design) | Tokens, fonts, WebGL scenes, the marks | `web/` depends on a tag; bumps arrive as pull requests | | [hyperstructure/fctl-site](https://github.com/hyperstructure/fctl-site) | fctl.sh: landing page, these docs, `/install`, `/releases` | renders `docs/` at the commit in its `fctl-docs.ref`, which this repository's CI updates when `docs/` changes on `main`; serves the releases `cargo xtask publish` writes to the KV namespace in `deploy/releases.toml` | | [hyperstructure/sandbox](https://github.com/hyperstructure/sandbox) | The default sandbox image, `ghcr.io/hyperstructure/sandbox` | its Image workflow publishes an image for each fctl release it finds on fctl.sh; hosts pull it, and `fctl host image build` builds its `main` ([Sandbox images](https://fctl.sh/docs/sandbox-images.md)) | ## Releasing Releases come from `main` through the **Release** workflow: Actions → Release → Run workflow, or `gh workflow run release.yml --ref main -f bump=auto`. It picks the version from the Conventional Commits since the last tag, adds the notes to `CHANGELOG.md`, pushes the release commit and tag with a deploy key (which starts CI on `main`, so app.fctl.sh redeploys), builds the binaries in parallel jobs (`cargo xtask dist build`: fctl for macOS and Linux on arm64 and x86_64, and the Linux agents), makes the archives from them (`cargo xtask dist package`), creates the GitHub release with the notes and publishes to `https://fctl.sh/releases` (`cargo xtask publish --latest`). Beside those jobs, the Standalone image workflow runs a session through the release's standalone control plane and then publishes its image to `ghcr.io/hyperstructure/fctl-control`; `gh workflow run standalone-image.yml --ref main -f tag=v0.1.2` publishes a release's image again. Within the hour, [hyperstructure/sandbox](https://github.com/hyperstructure/sandbox), where the default sandbox image lives, publishes the release's image to `ghcr.io/hyperstructure/sandbox`: its Image workflow looks for new releases on fctl.sh every hour. ```sh cargo xtask release --dry-run # the next version and its notes cargo xtask notes v0.1.2 # one release's notes ``` The steps and what to do when one fails are in [.claude/skills/release/SKILL.md](https://github.com/hyperstructure/fctl/blob/main/.claude/skills/release/SKILL.md); commit and pull request conventions in [.claude/skills/commit/SKILL.md](https://github.com/hyperstructure/fctl/blob/main/.claude/skills/commit/SKILL.md). To build locally: `cargo xtask dist` (or `--target aarch64-apple-darwin`). It does what the Release workflow does in separate jobs: `cargo xtask dist build <fctl|fctl-agent> --target <triple>` for each binary, into `dist/bin/v<version>/`, then `cargo xtask dist package`. ## Deploying `main` deploys `app.fctl.sh` from CI (fctl.sh deploys from hyperstructure/fctl-site). The jobs that deploy, make and publish releases or pin fctl.sh's docs run in the `production` environment, which holds their secrets (the Cloudflare token, the deploy keys that push release commits here and docs pins to fctl-site, and the token that starts the Homebrew tap's bump) and which only `main` may use, so workflows on other branches cannot read them. Rulesets keep everything else off `main` and off the `v*` tags ([ADR 0021](https://github.com/hyperstructure/fctl/blob/main/docs/adr/0021-rulesets-protect-main-and-release-tags.md)). By hand: ```sh cargo xtask deploy fctl-sh ``` Cloudflare's API throttles a token, an account or an address that has made too many requests: it answers 429 with a Retry-After, for up to five minutes, even to a deploy's first request. Wrangler gives up at once in its D1, KV and secret commands, so `cargo xtask deploy`, `secrets` and `publish` run a throttled wrangler command again once the wait is over, up to four times (`util::retry_throttled`).