The /code panel: operating it¶
For operators
Deploying and running the Agents panel; users using it want Agent computers and its reference.
The Agents panel drives coding agents running on users' own computers
from the chat's sidebar. The agent (opencode, or any ACP agent) runs on the
user's computer under galopin, a single binary built from this
repository's agent/. galopin dials out to the deployment over WSS and
authenticates with its own OIDC credential. The deployment runs no model and
stores no code for it.
This page is for operators. The user using the panel wants Agent computers (getting started) and the reference instead.
A session waiting on a busy model says so: when the provider answers "rate limit" or "overloaded" and the agent retries with backoff, the view shows "Retrying — <the provider's message> (attempt N, next try in S s)" beside Stop instead of a silent running turn. This needs the galopin update; an older computer keeps the plain running look.
What gets deployed¶
Nothing extra. The computer link is one WebSocket endpoint
(GET /api/v2/code/machine), served by the chat process itself. There is no
relay, no extra container and no extra port. The chat also serves the galopin
binaries and their checksums at <base>/galopin/, with an installer at
<base>/galopin/install.sh.
Turning it on¶
In the deploy kit: ./configure --agents at install time, or
./configure --set CODE_AGENTS_ENABLED=true on an existing install, then
docker compose up -d.
| Variable | What |
|---|---|
CODE_AGENTS_ENABLED |
true shows the Agents switch and serves /code; anything else hides it and /code answers 404 |
CODE_TERMINAL_ENABLED |
true allows browser terminals on computers that also allow them (below); off by default |
CHAT_SCHEDULES_ENABLED |
on with the panel; exactly false is the kill switch for scheduled actions (below) |
CHAT_SCHEDULES_MAX_PER_USER |
how many schedules one user may have; default 20 |
CHAT_VAPID_PUBLIC_KEY / _PRIVATE_KEY / _SUBJECT |
optional: Web Push keys, so users are notified when an agent needs them (Notifications for agents); the kit creates them |
CODE_MACHINE_ISSUER |
the issuer a computer's token must come from; defaults to the chat's own (OPENID_PROVIDER_URL) |
CODE_MACHINE_AUDIENCE |
the audience a computer's token must carry; default pystino-api |
CODE_MACHINE_CLIENT_ID |
the client a computer's token must be issued to; default opencode-enrollment |
The last three only need setting when computer tokens come from somewhere unusual; the deploy kit's defaults match its bundled Authelia.
Who may use /code¶
CODE_AGENTS_ENABLED=true is the outer switch and always wins: off hides the
Agents switch and /code answers 404 for everyone. When it is on, a further
per-user answer applies on a Pystino gateway deployment: the gateway's
code_agents capability (set per deployment and granted per group in the
gateway console — Chat-only
users).
A user without it never sees the Agents switch, /code answers 404 for
them with "not available for your account", and every pairing, device,
schedule, file and terminal endpoint refuses them the same way — the panel
reads as hidden, not as a refusal to argue with. Their computer link is closed
(4404, the device staying enrolled) and reconnects by itself once a group
grants the capability. A deployment with no gateway (the generic preset) is
decided by the env switch alone, as before; so is an older gateway that sends
no capabilities field — absent reads as allowed, never as a refusal.
Setting up a computer¶
On the computer, with <origin> the deployment's origin:
curl -fsSL <origin>/chat/galopin/install.sh | sh
galopin enroll --issuer <origin>/authelia --gateway <origin> --cerea <origin>/chat \
[--no-terminal] [--deny CLASS] [--workspace-root PATH] …
galopin run
The pairing dialog prints this as one command, one step per line, calling the
installed binary as ~/.local/bin/galopin. With its Install opencode box
ticked it adds opencode's own installer, pinned to the release galopin is tested
against (agent/packaging/opencode-version), before enroll.
The installer checks the binary against the deployment's SHA256SUMS and
refuses a mismatch. The computer then appears in the panel as pending
until its owner confirms it.
galopin run in a terminal stops when the terminal closes. To keep it running,
the computer's owner installs it as a systemd user unit on Linux or a
LaunchAgent on macOS (Keeping it running);
to update galopin, the computer's owner re-runs the install line and restarts it
(systemctl --user restart galopin); no re-enrollment is needed, and the
computer keeps its pairing and policy (Updating galopin).
Two vetoes: the computer's and the deployment's¶
The computer's owner decides at enroll time what the computer allows (files, terminals, a local deny of a kind of action, slash-command shell, models from elsewhere, workspace roots). The panel's Pair a computer dialog offers every one of those flags, so nobody edits the printed command by hand. Those settings are stored on the computer, and the chat can never loosen them over the link. The table of every flag, its default and what it decides lives in one place, The computer policy; the owner can tighten a computer later without re-enrolling, but loosening needs a new enrollment.
The terminal needs both sides to say yes. The computer allows it unless it
was enrolled with --no-terminal (a computer enrolled before terminals were on
by default needs enrolling again), and the deployment must set
CODE_TERMINAL_ENABLED=true.
Everything in /code needs a sign-in within the last 7 days; the computer's own
link is unaffected (see below). A terminal also re-checks that every minute.
An open terminal is an ordinary shell running as the computer's owner:
anyone who controls that user's Cerea session can run commands on the
computer, with no model and no permission rule in between. Enable it only
where that is acceptable: enabling it lets a Cerea session change the
computer's local policy (a shell can edit policy.json and the config
galopin starts the agent with). Both sides record terminal opens and closes,
never their content.
What the fixed floor and the local deny are, until the OS sandbox exists.
They are galopin refusing or asking about the calls the agent shows it, which is
as advisory against bash as any tool-level rule: a shell can do anything the
ask for it does not name. A local --deny command is meaningful against a
hijacked Cerea session; --deny web while commands may run is not (a command can
fetch), and galopin enroll / galopin policy set warn about that combination. A session
created by an older galopin may still show the stuck Deny banner (its
stored rules are append-only); start a new session to clear it.
The 7-day sign-in¶
Everything in /code needs a sign-in within the last 7 days
(src/lib/server/code/stepUp.ts); the computer's own link is unaffected. It is
one deny-by-default guard in hooks/handle.ts: every request under
/api/v2/code/ except GET /api/v2/code/status answers
401 {code: "reauth_required"} while the session's authTime is older than the
window, or missing (the hook reads it off the session document it already
loaded, with no extra query). A route added under that prefix later is
protected without anyone remembering to; the guard spec fails if one appears
that it has not been told about.
/status is the one open route. It returns only
{enabled, signedIn, fresh, reauthPath, signInPath} plus freshUntil when
fresh, and nothing derived from a computer: no names, counts or inbox badge.
reauthPath is a /login?reauth=1&next=/code path (with the app base) for the
Sign in button; signInPath is the same destination without the force, for
a caller with no session at all. A session can be unusable two ways, and the
panel says which: stale (signed in, older than 7 days) gets the forced
re-login, signed out (no session at all — the ordinary expiry of a chat
without a refresh token) gets the plain sign-in, which the identity provider's
own SSO session answers silently. A signed-out open tab leaves for that plain
sign-in by itself (guarded against a loop: a redirect that already happened in
the last minute is not repeated, and the card shows instead); with
Authelia's bundled session settings, ticking Remember me at sign-in is what
makes that re-login silent — without it the provider asks for the password
again after an hour regardless of Cerea.
Three things the hook cannot see, each handled where it lives: the computer link
and the terminal socket are WebSocket upgrades that bypass hooks (the terminal
re-checks freshness in its own 60-second loop and closes with 4403
reauth_required), and an event stream opened while fresh ends itself with a
reauth_required frame at authTime + 7d. The page asks /status on load, and
a shared codeReauth store flips on any reauth_required answer or on a
freshUntil timer: the panel then closes its streams, drops its in-memory
computer state and shows one card. The Needs-you inbox goes dark with the rest,
which is the price of the guard covering every route.
How computers are trusted¶
- Tokens are checked locally. A computer's access token is verified against the issuer's keys (JWKS): the issuer, the audience, the authorised client and the expiry must all match, and ID tokens are refused. The token's subject must belong to someone who has signed in to this chat; if not, the connection is refused.
- A user confirms each computer. The first connection creates a pending device. Nothing is sent to it until its owner clicks Confirm in the panel. Reject and Revoke tombstone the device: that computer id is refused from then on, its connection is closed, and galopin revokes its own refresh token and exits.
- The browser never talks to a computer. Every browser request goes to the chat, which maps each allowed path onto exactly one computer operation, after checking that the device belongs to the caller.
What the chat stores¶
- Pairing records (the
codeDevicescollection): a name, the computer id, the token subject and issuer it last used, the capabilities and policy it reported, and its credential health. None of it can reach a computer; only a live connection can, and those are held in memory. - Files attached to agent messages, in the chat's attachment store, so they still show after a reload. They are deleted when the device is.
Everything else (workspaces, sessions, transcripts, the code) stays on the computer. That includes images a tool produced (a browser tool's screenshot, an image the agent read): the panel fetches each from the computer whenever it is shown, and the chat keeps no copy.
What the panel does¶
An affordance shows only when the session's backend reports the capability, and the computer's policy can still refuse it.
| Feature | What the user sees | How it works |
|---|---|---|
| Transcript | the chat's message column, opened at the newest message | One subscription replays the session's whole log (session.sync) and then tails it live. The replay folds behind a Loading chat… skeleton, and the transcript renders once, landed at the bottom — a long session never renders progressively from the top, and nothing is scrollable while it loads, so a scroll during the load cannot strand the reader away from the newest message. After that, live turns stick to the bottom as anywhere in chat, until the reader scrolls up. The replay's end is a historyDone marker frame the bridge emits (Cerea-internal, no computer involvement). The replay carries only the newest page on a computer that pages (session.sync's limit, capability historyPaging); scrolling within about one screen of the top fetches the previous page (GET v1/agents/:id/history, op session.history) and prepends it with the visible message held where it was — a Loading earlier messages… row while it loads, a quiet Start of the session marker at the top, a retry row on failure. Older computers send the whole log as before. Going back to a session you had open shows it at once: the last 8 sessions are kept in this tab's memory (never stored on disk) and the fresh replay swaps in when it completes; until then the copy is read-only (no paging, retry or fork; a permission card waits for the refresh). |
| Modes and models | pills in the composer | Live lists (backend.modes / backend.models). Non-gateway models (anything outside pystino/*) are hidden unless the computer was enrolled with --allow-free-models. The agent filters them and reports how many it hid; Cerea filters again and answers 403 to a disallowed set or create (src/lib/server/code/modelPolicy.ts). |
| Permission selector | a Plan · Ask · Auto · Allow selector in the composer's pill row, and its note | The computer's word, never a guess. GET v1/agents/:id carries permissionMode (session.get; a subagent reports its root's); the selector shows it, and POST v1/agents/:id/permission-mode {mode} (session.setPermissionMode) sets it, after which the view re-reads the snapshot and permission.rules rather than claim the new word from the click. A bad mode is a 400 here; the computer answers invalid for a subagent's id (it follows its root) and not_found for an unknown session, and both come back as their HTTP statuses. What a session will do on each kind of action is in the shield's sheet, not beside the selector. Disabled on a subagent's view, hidden on a computer that sends no permissionMode and while the sign-in is stale. The change is audited here as permission.mode (session and mode). New sessions start on Ask. The selector's first word is Plan, which the wire still spells deny (session.setPermissionMode also accepts plan): the computer refuses every call that would change something, and choosing Plan in the panel also switches the session to the plan agent (Ask and Allow switch back to build; a custom agent the user picked stays). The agent picker lists only the custom agents while the selector shows. |
| Permissions | chat's approval card, and a Permissions dialog opened from a session's ⋯ menu or from the shield button beside the selector | galopin answers every ask (reject, once or a card); Cerea shows the cards. galopin writes no rules into opencode (ADR 0097). permission.asked becomes the card; Allow once / Always allow (this session) / Deny become permission.reply once / always / reject, for galopin's own gp_ asks and opencode's alike. "Always" adds a per-session exception (the computer answers opencode "once" and stores it itself, scoped by the computer, not by the agent's proposal: an edit's is the file(s) of that ask, exact, so the card says Always allow edits to this file; a command keeps a specific prefix or falls back to the exact command; a path outside the project is its directory; with no concrete scope the answer is once and nothing is stored), and the card offers the button on every card a human answers (the /code transcript, the Needs-you inbox), including a credential ask, which is a card even on Allow: there the reply carries actor: {kind: "user"} and the computer stores the exception marked overAsk, the only kind that holds there, which later matching asks in that session run under (Plan still holds exceptions back). Only the loop brake stays once-only; a local --deny and the computer's own files still refuse, and a reviewer's or rule's answer is never "always". The dialog reads GET v1/agents/:id/permission-rules (permission.rules) itself — the session's Exceptions and its coordination grant — so it can be opened for any session's row, selected or not, and shows the same content while the transcript stays in view: one row per kind of action with what happens and why (computed from the computer's limits, the administrator's rows and the reviewer's availability; no backend rule is listed) and the exceptions as Allowed for this session: … with Remove. DELETE v1/agents/:id/permission-approvals/:id (permission.saved.remove {id, sessionId}) removes one exception, so that command asks again, and can only tighten; it is audited here (session and exception id, never patterns) and on the computer. There is no free-form rules writer. No route reaches the ceiling, the computer's own rules or its policy. A Coordination section in the dialog holds two switches — Can find, read and message other sessions and Can start new sessions — read from permission.rules' coordination field and written by POST v1/agents/:id/coordination {keys} (session.grantCoordination), in the two shapes galopin composes (the message trio together or not at all, the spawn key on its own; [] clears); a partial set is a 400 here, a subagent's id and an unknown key are the computer's 400, and an old galopin is a 404. The grant lands before the session's next turn; a spawned session inherits it. The selector, the shield button, Remove and the switches sit behind the stale-sign-in guard like every other route under /api/v2/code/. |
| Questions | the SAME card chat's own ask_user_question uses, lifted to the composer |
question.asked/question.resolved (opencode's built-in "question" tool, the questions capability), each Question normalized to one select field via AskQuestion.svelte's onanswer prop; POST v1/agents/:id/questions/:requestId translates accept/decline into question.reply answer/reject. Not offered when the backend's capability is false (ACP: always). |
| Stop | chat's stop button | session.cancel; an abort ends the turn normally, it is not shown as a failure. |
| Usage and context | a ring in the composer (ContextMeter.svelte) |
The usage side channel: a percentage when the model's context window is known, otherwise a token count; "Compact now" calls session.compact. Cost and token counts are not the engine's: galopin's proxy stamps every model request it forwards with the session (x-pystino-tag, the top-level session, so a subagent counts toward its parent; an engine that names no session is attributed to the one session whose turn is running, and to none when several run), Pystino stores the tag on its usage row, and the popup shows the session's cost and tokens (and the latest turn's, from the moment this page saw it start) as metered by the gateway (GET /api/v2/code/agents/:id/usage, asked with the signed-in user's own token, so only their own requests can be read). This is the same for every engine. When the engine reports no context figure the ring shows the gateway's token count instead; with no figures from either side it is hidden. A Pystino older than this change, or a request galopin could not attribute, simply has no figures. A turn's cost is complete once its last request has finished, so it is read again when the turn ends. |
| Changes | the Changes side pane | session.diff, which asks git for the workspace's uncommitted changes (untracked files included, capped) rather than trusting a backend's own diff, which misses files a shell command wrote. Outside a git work tree the list is the backend's: opencode's own diff, plus galopin's before/after snapshots of the edit tool calls it saw for any file opencode did not list; for an ACP engine (goose, pi) galopin's snapshots are the whole list. A binary or oversize file reads as changed without content, and a credential, .env or galopin-own file is never read (it shows as changed, content not shown). |
| Subagents | a card at the task call that spawned it, expandable into the child's own transcript | session.children, each child carrying parentToolCallId; children are not listed as sessions of their own. A background child (background:true, unless the computer was enrolled with --no-background-subagents) outlives its parent turn — the strip says so — and its result folds into an automatic marker; a task_id resume reads as a follow-up. |
| Sessions talking to sessions | "Spawned …" / "Sent to …" / "Read …" cards, linked to the other session | session_spawn / session_send / session_read tool calls (agent-tools). Whether each asks is decided by the computer's own rules for the asking agent (session_spawn / session_send / session_read), or by a grant a schedule gave the session: allow shows an allowed by this computer's rules badge and no card, deny refuses, ask or no rule raises a card. A session started with session_spawn is a delegate of the session that started it: those two read and message each other with no card, and when the delegate finishes a turn its spawner started, galopin puts its final message in the spawner's transcript as a message from that session ("Report from |
| Delegating | a Delegate profiles dialog in the computer's ··· menu; "Spawned …" cards for committee members, and one gathered report | profiles.list / profiles.set (above) and the session_profiles / session_committee tools. A committee is several session_spawns through the same checks (one card per member under Ask), whose reports galopin gathers into one message to the spawner. See Delegating work. |
| Images and files | the chat's attach picker, dropping a file onto the composer, chips and rendering | See Attachments below. Offered when the backend advertises files or images. |
| Forks | "Fork from here" on a finished assistant message | POST v1/agents/:id/handoff: a new session (same computer or another of the user's paired computers, any allowed mode/model), prompted with the user's text plus the chat up to that turn as a chat-history.md attachment — the dialog always carries it (a fork without the chat would just be a new session), while the route's carry flag stays optional. The new session is titled Fork: … (older forks: Handoff: …) and says where it came from. |
| Retry and rollback | ↻ on an answer, or editing a prompt (when the computer reports revert) |
POST v1/agents/:id/revert {messageId} rolls the session back to before that prompt (opencode POST /session/:id/revert), then the prompt (or the edited text) is sent again. The confirmation says whether files come back: opencode restores them from its snapshots in a git repository only. POST v1/agents/:id/unrevert undoes it before the next prompt. |
| Workspaces and worktrees | path autocomplete in "Add workspace"; "New worktree…" on a git workspace | workspace.suggest (inside the computer's workspaceRoots, or $HOME with none) and workspace.create {worktree} (git worktree add, branch and base of the user's choosing); archiving a worktree workspace can also remove the worktree. |
What the coordination gates do not cover
An approved shell command can do anything you can on that computer (opencode's server password sits in its process environment, readable by same-user processes); the coordination gates constrain the model's tools, not an approved shell. See Sessions that talk to sessions.
Tool images (the toolImages capability)¶
When a tool call produces an image, its card shows it under "Output". The computer lists the image on the tool part (a sha256, a type and a size) and keeps the bytes: galopin holds up to 64 MiB of recent tool images, and for opencode an older one is re-read from opencode's own history. A user's browser asks the chat for the image, the chat asks the computer, and the answer is cached in the browser only.
- Only PNG, JPEG, GIF and WebP are shown, inline as images. SVG is never displayed inline, whatever the computer calls it.
- The chat does not take the computer's word for the type. It checks the file's own header, refuses anything over 8 MiB or past about 50 megapixels (a tiny file can decompress to gigabytes), and refuses bytes that do not match the checksum the tool part listed. Seeing a broken image usually means the computer no longer has it: an ACP agent keeps images in memory only, so a galopin restart loses them.
- Up to 8 images are shown per tool call. An image the agent saved as a file is the file explorer's, not this.
Slash commands (the commands capability)¶
The computer lists each workspace's slash commands (backend.commands):
name, description, where the definition lives (the agent's own init/
review are builtin; a name only the workspace list reports is
project — repository code, listed only when the computer was enrolled with
--allow-project-config; by default a repo's own opencode config is
ignored), whether its template expands shell, the
exact snippets it would run (at most ten, each truncated), the @path
files it reads, and a hash of the template. The template itself never
crosses the link — the snippets and the hash are what a user confirms
against.
session.command runs one behind the computer's gates, each refused before
anything runs and each audited in the computer's own audit.log as
command {name, origin, shell, decision} — never the arguments, never the
expanded text:
- unknown name →
not_found(the list is re-read at run time, never trusted from an old menu); - the panel's confirmation carried a
templateHashthat no longer matches →conflict(the definition changed since it was reviewed); commandShell: denied(the default) and the template expands shell — or its shell behaviour is unknown (MCP prompts, ACP commands) →forbidden;- an
@pathref matchingfileDeny→forbidden; - a command pinned to a non-gateway model with
allowFreeModelsdenied →forbidden(the same gatesession.setModelapplies); - a command naming a more permissive agent while the session sits in a
more restrictive mode → the session's mode is sent instead and the
command's agent ignored; a
subtaskcommand in that spot is refused outright (it would run unsupervised in the other agent).
The capability is probed, never versioned: opencode advertises commands
only when its own GET /doc lists the operation (cached per process,
re-probed on restart). Cerea maps a 404 to "panel commands only".
The panel's first run of a project or shell-expanding command opens a
confirmation sheet (snippets, file refs, "running a project command is
running repository code"), remembered per device in localStorage keyed
by the template hash; a later run whose hash differs answers the 409 above
and the sheet re-asks.
A computer can run an ACP agent instead of opencode (galopin run --backend acp
--acp-command "<agent>"): Gemini CLI, Claude Code or Codex through their ACP
adapters, or opencode acp. Those report fewer capabilities (no usage,
compaction or subagents), and the panel hides the matching controls.
An ACP agent that can use an HTTP MCP server still gets galopin's coordination
tools (session_*, schedule_*, profiles and the committee) as an MCP server
galopin runs on loopback, one endpoint and token per session, under the same
approvals as opencode's. Nothing about it is configured on the deployment: it
is the computer's, and --no-agent-tools turns it off (see
Coordination tools over MCP).
Computer settings and delegate profiles¶
Each computer's row in the sidebar has a ··· button (Computer settings).
Its one item, Profiles, opens the Delegate profiles dialog: a list of
up to 32 profiles, each a name and a model, with one marked the default. When
the agent on that computer hands work to another session (session_spawn, or a
committee through session_committee) it can name a profile, or take the
default's model; with no profiles the delegate runs on the spawner's own model.
What users see and how delegation uses profiles is in
Delegate profiles.
What the operator should know:
- The computer owns the set. It is stored in
profiles.jsonin galopin's state directory on the computer, not in the chat's database. The panel reads it withprofiles.listand replaces it whole withprofiles.set, which the computer validates (the name pattern, at most 32, each model one it offers, the default) before writing anything; a refusal comes back as the sentence the dialog shows. Each write is aprofiles.set {count, default}row in the computer'saudit.log. - Only the device's owner may call them, the same ownership check as every
other
/coderoute for a device, and the 7-day sign-in rule applies. They are under/api/v2/code/, so a stale sign-in answersreauth_required. - The dialog is read-only, and says why, for a computer that is offline,
was enrolled with
--no-agent-tools, or runs a galopin that predates profiles (the hello'smachine.capabilities.delegateProfilesis absent). An older Cerea never sends the ops, so mixed versions degrade to "not available". - A model is not a permission. A profile changes the model a delegate runs on
and nothing it may do: the spawner's permission word and grant, a local deny
and the approval card are unchanged. Read-only delegation is
the
planmode ofsession_spawn/session_committee.
Plan, Ask and Auto¶

The composer's permission control has four steps: Plan (the agent may only look and plan), Ask (every action that needs permission asks you first), Auto (each kind of action runs, is judged by your reviewer, or comes to you, as you set it) and Allow (hands off). On the wire they are the computer's deny, ask and allow; Auto is ask plus a per-class map (autoClasses: run or ask for each kind of action, session.setPermissionMode), the map's presence being what makes a session Auto, and Allow is allow with none. Cerea works the map out from your rows when you pick Auto; the browser never names it.
There are no computer limits (ADR 0109): the user who uses Cerea is the user who installs galopin, and the hard limit is the sandbox. What a session does comes from its step, your rows and a fixed floor. On Auto, a kind of action you set to Runs runs with no card. A kind you set to Reviewer or You raises a card; a reviewer card goes to the reviewer when your reviewer is on, the deployment switch is on, the computer is not opted out and the reviewer is reachable, and otherwise comes to you, so Auto never quietly becomes Allow. The reviewer allows an action once when it is sure enough, or sends it to you with its reason. Allow is hands off: everything runs except the fixed floor (galopin's own state is refused; credential and secret paths, writes to .git and CI configuration, and a looping agent still come to you). Secrets and loops never run on Auto either. You choose your rows, model, threshold and instructions in Workspace → Agents; your administrator only switches reviewers on for the deployment.
How the reviewer decides. An ask that goes to the reviewer is held back from the "An agent needs you" notification while the reviewer looks at it, which takes a second or two. It sees the kind of action, the tool, the command or path, the session's title and the workspace, with the text of the action marked as data it must not obey. It answers with a probability that allowing is safe; at or above your threshold the action is allowed once and the agent carries on, below it the ask comes to you. Some asks never go to it and always come to you: the agent's own configuration on the computer, credential files and .env, and writes to CI configuration, and editor writes into a repository's .git directory (its config, hooks and info files), which the computer itself also raises as a card even when edits run. Loops always come to you. If the reviewer cannot be reached, answers something unusable, or the computer has used up its hourly allowance, the ask comes to you as it does without Auto: the reviewer can never fail into an allow. If nothing has been decided after a minute the notification goes out anyway.
Plain reads skip the reviewer. This is a noise and latency reduction, not a security measure: a harmless read such as docker compose ps does not need a model's opinion, and asking one made the user wait and sometimes sent the card to them anyway. On Auto, a command the computer recognises as a plain read inside the workspace is allowed once straight away by a fixed rule, without asking the reviewer model, using none of the hourly allowance. The computer decides this itself by parsing the command: simple commands joined by |, ;, && or ||; no file redirects (only >/dev/null, 2>/dev/null, 2>&1), no substitutions, variables, wildcards or brace expansion, no subshells or functions; every program on a short read-only list (cat, head, tail, wc, non-recursive grep, ls, ps, df, du, find without -exec/-delete/-fprint, sed without -i, git status|log|diff|show, docker ps|logs, docker compose ps|logs, systemctl status, journalctl) with its own argument rules (no follow flags, no commands that run something); and every path it names, symlinks resolved, inside the workspace and clear of credential files, .env files and galopin's own files. Anything it does not recognise is not a plain read and goes the usual way. Commands that can print secrets without naming a file (env, docker inspect, docker compose config) never qualify. The flag comes from the computer only, never from the agent. The rule needs the reviewer to be turned on (the deployment's switch and your own) and your row for commands to say reviewer; a computer's Never auto review requests switch is about the model, so the rule still applies there. The always-asks still win, and Ask sessions still show the card. The review log shows these as Allowed: read-only, with no probability.
Auto's rules are guardrails against a careless agent, not a security boundary; see ADR 0107 and ADR 0109.
What you see. An action the reviewer allowed shows Auto-allowed by reviewer model on its card; a plain read allowed by rule shows Auto-allowed: read-only. An ask it sent to you says why, above the buttons and in the notification: The reviewer wasn't sure enough (51%; it allows at 90%), This kind of request always comes to you: ⟨reason⟩, or The reviewer couldn't be reached, so this comes to you. The line under the control adds Auto: reviewer unavailable while the reviewer keeps failing. Every decision is in your review log (Settings → Review log; administrators also see all users' under Administration → Agents), and galopin's own local audit records the action as answered by a review model rather than by you.
The reviewer calls the model with your own sign-in access, the background access that scheduled chats use, so reviews count against your own quota. If you have none (sign in again to get it), or you have turned background access off, your asks come to you and the log says why. This works on deployments behind a gateway; elsewhere the third step reads Allow.
Auto and Allow are always two separate steps. When there is no reviewer behind Auto (none is switched on for you, the deployment switch is off, or you opted this computer out), the kinds you set to Reviewer come to you instead, and the sheet says so.
To see what reaches you, tap the shield beside the control (its name carries the step, for example "Permissions: Auto"). The sheet opens with one sentence for the selected step, for example Auto on studio-laptop: edits, web, agents and tools run · commands and outside access are reviewed · secrets and loops come to you. On Auto it then lists each kind of action with what happens (runs, reviewed, comes to you) and why: set in Workspace → Agents, always (secrets and loops), no reviewer switched on for you, or you turned the reviewer off for this computer. There is no computer limit to show. Nothing about it sits under the composer. Below are the session's exceptions (what "Always allow" left behind, an edit's shown as the file path it covers, each with Remove) and the Coordination switches, which let the session find, read, message and start other sessions without asking each time.
Never auto review requests is in each computer's ⋯ menu in the sidebar. It is stored per computer for you, and the reviewer never sees that computer's asks.
Pairing sets no limits. The Pair-a-machine dialog has no limits table and prints no --limit; a computer that still sends limits (an older galopin) is read as if it sent none. Repeating agents still stop at the floor: a loop is always a card, and the transcript says Stopped: the agent was repeating the same action as a plain line, not as an error, when the agent is cut off.
Scheduled actions offer Plan, Ask and Auto. A schedule's Auto runs under your own rows, like a session on Auto. A project's agent profile (Project → Coding agent) can set the default step for sessions started from its chats to Plan, Ask, Auto or Allow, so "this project is hands off" is a project setting and leaves your reviewer settings alone.
The review log¶
Every decision the reviewer makes on Auto is recorded: the time, the computer, the session, the kind of action, a shortened command or path, the probability, the outcome (allowed, allowed as a plain read, sent to you, error) and whether the answer was applied. Each user sees their own under Settings → Review log (the link is also in a session's Permissions sheet); an administrator sees everyone's under Administration → Agents → Review log. You can filter by computer, outcome and kind. Rows are kept 90 days and erased with the account. The log is there when Agents is on; without it the page says it is available where coding agents are turned on.
Connectors for an agent¶
A coding session can use your connectors: the ones in Workspace → Connectors (your own and your administrator's) and the servers of your own computers (local connectors). Off by default: a session gets none until you switch them on, so it carries only what it needs.
- For one session: open the shield beside the permission step (the session's Permissions sheet) and use the Connectors switches. A connector that is switched off in Workspace, or needs a sign-in or a key, shows off and disabled with the reason. A server on the session's own computer shows "on this computer".
- As a project default: under the project's Agent workspace, switch on the connectors that sessions started from that project's chats get. It applies to sessions the chat starts on that computer and workspace; a session that already exists keeps its own.
The agent has two tools: connector_list (what is on, with each tool) and connector_call (one tool of one connector). Cerea runs a call to a Cerea connector with your own sign-in or key, and the credential never reaches the computer; a call to a server on the computer runs there, without going through Cerea. Whatever the chat would not offer you is not offered here either: a connector your administrator switched off, or you switched off in Workspace, stops working at once, even in a session you had switched it on for.
A connector call is a tool call like any other connector tool: a local --deny tools refuses it, Plan refuses it, Ask shows you a card with the connector, the tool and its arguments, and Allow runs it. In Auto the card goes to the reviewer or to you by your row for tools, and the review log records it. A call is limited to 60 a minute per computer and 80 seconds, its text to 200,000 characters. Cerea records the connector, the tool and the outcome in the audit trail, never the arguments or the result. Needs a galopin that has this feature and a Cerea that offers it; otherwise the tools say so. Agents without MCP or tool files (pi, over ACP) do not get connectors yet.
Where an agent's asks show up¶
One component, the Needs-you inbox, draws every pending approval and question with the same cards an agent's transcript uses, and answering anywhere clears it everywhere. It appears in three places, for a user with /code on and a fresh sign-in:
- On /code, at the top of the page.
- On every other page, as a bar at the top of the main column, shown only while something is pending. It starts collapsed ("2 need you") and the choice is remembered for the browser session. It is not drawn on the sign-in routes, and it asks the computers only while the tab is visible, on the device poll's 8-second cadence (no request at all with /code off, a stale sign-in or no paired computer).
- Above the composer of a chat that started or prompted agent sessions: the asks of exactly those sessions, already expanded, answerable in place. The app-wide bar then leaves them out, so one question is never on screen twice (the same rule it applies to the session open in the agent view).
The Agents option of the sidebar's Chats | Agents switch carries a count badge while asks are pending (that badge is all: there is no separate "Needs you" line in the sidebar). Push notifications (below) are unchanged.
Notifications for agents¶
With Web Push set up (Notifications: the
CHAT_VAPID_* keys, which the kit's ./configure creates once), users who
turned notifications on are told about their agents even with the tab closed:
| Event | When |
|---|---|
| An agent needs you | a session asks for an approval or a question (the cards the Needs-you inbox lists) |
| A long agent turn finishes | a root session goes from busy to idle after more than a minute and a half (error if failed) |
| A scheduled run finishes | the scheduler's own outcome (sent, failed, or computer offline), in place of the turn event |
| A background task fails | a task an agent started in the background ended with an error; a Stop is not a failure |
The chat watches every event of every session on a computer link whether or not anyone has the panel open, because the user with the tab closed is the one who needs the push. The message carries a title and one short line, never the command, a file name or the agent's words (a lock screen shows it), and a user is sent at most twelve in five minutes. Which events a user hears about is their own choice under Settings, Notifications, and applies to all their devices. State for turns in flight is in memory: a chat restart can lose one notification and never sends a wrong one.
Scheduled actions¶
Users can put a prompt on a timetable: Schedules, at the foot of the
sidebar (the same row in Chats and Agents; /code?view=schedules redirects to
/schedules?kind=agent). A schedule names a computer, a workspace on it and either "a new
session each run" or one existing session, and sends its prompt at the times
set (every N hours, daily, weekdays, weekly, or a cron expression; at least 15
minutes apart; read in the schedule's timezone, DST included). A schedule can
also be told to stop after a number of runs: once it has fired that many
times it switches itself off, with the reason shown on it. A fire that sent or
failed counts; a skipped or missed one does not, and "Run now" never does.
Editing the cap below the count already reached, or re-enabling a schedule that
is at its cap, switches it off rather than firing again.
What runs where. The scheduler is a loop inside the chat process (every 30
seconds). It claims a due schedule atomically in MongoDB, so two chat instances
never fire the same run, and it moves the next run forward before it runs
anything. The run itself is the panel's own operations over the computer link:
create a session, set its Plan (deny) / Ask / Auto / Allow word, (optionally) grant its
coordination tools, send the prompt. Nothing runs in the chat; the session
is the computer's, and it pays for the run with its own credential, as for any
other prompt.
Every scheduled prompt starts with one line the user did not write:
[Scheduled run of "<name>", <timetable> (<timezone>); previous run <3 h ago | none>; coordination: <keys granted | none>],
then a blank line and the prompt as written. It tells the agent nobody is
watching live, when it last ran and which session tools it may use without a
card, so it can avoid repeating itself across runs. galopin's
delegation skill tells the model how
to read that line (src/lib/server/schedules/runHeader.ts writes it).
Rules the operator should know.
| Situation | What is recorded |
|---|---|
| the computer is offline at run time | missed-offline; nothing is queued |
| the computer was revoked | the schedule is switched off, with the reason shown on it |
| the previous run's session is still working | skipped-still-running |
| the workspace or pinned session is gone | failed with that reason; three failures in a row switch the schedule off |
| the chat was down when a run was due | one catch-up if it is under half the interval late (at most an hour); otherwise missed-downtime |
Every occurrence, fired or not, is a row in scheduleRuns: that is the
history users see per schedule and the audit record. Rows are kept 90 days.
The Ask warning. Unattended, a session on Ask stops at its first
approval and waits. The approval card appears in the Needs-you inbox. The
editor says so beside the selector. Allow is for work that must finish on
its own, and the fixed floor still comes to you (credential files, .git and CI
writes) and a local --deny still refuses.
Working with other sessions. Two options in the editor, both off:
Can find, read and message other sessions (session_list, session_read,
session_send) and Can start new sessions (session_spawn). They let an
unattended run orchestrate the computer's other sessions without stopping at an
approval card. At each run, after the session is created or chosen and before the
prompt, Cerea sends the computer session.grantCoordination {sessionId, keys}
(see Granting a session its coordination tools).
The grant is the computer's to limit: a key it denies locally (--deny agents) is refused (the editor says so), another
workspace's sessions still ask, the hop and rate limits stand. A galopin that
predates the op is not an error: the run goes ahead without the grant and its
row says "this computer's galopin is too old to grant coordination; update it"
(the editor shows the same warning once such a computer is chosen; a computer
enrolled with --no-agent-tools says so instead). A schedule pinned to one
session sends the grant each run, and when its options are turned off the next
run takes the grant back (keys: []).
Schedules made by agents. A coding session can list, create, update, pause
and delete schedules on its own computer through galopin's schedule tools (see
Scheduling work for when those ask). They
are call frames on the computer link, answered by server/code/machineCalls.ts
as the computer's owner: Cerea's welcome advertises
features.machineCalls: ["schedule"], and a galopin with the tools reports
scheduleTools among its backend capabilities. The ops are schedule.context
(is the caller's session, or its root, a run of a schedule: looked up in the
run records), schedule.list, schedule.create, schedule.update and
schedule.delete. On top of the store's rules (floor, cap, timezone, switch),
Cerea refuses:
| Refused | Answer |
|---|---|
| another computer as the target, or a workspace not on this one | invalid |
| a permission mode looser than the calling session's (deny < ask < allow) | forbidden |
| coordination the calling session does not have itself | forbidden |
| a sixth running agent-made schedule on the computer | limit |
| an eleventh change in an hour from the computer (pause and delete exempt) | limit |
| updating or deleting another computer's schedule | not_found |
anything but schedule.context while CHAT_SCHEDULES_ENABLED=false |
unavailable |
A new prompt, a new target or switching a schedule back on is checked against
the caller too, so an Ask session cannot rewrite a user's Allow schedule. A
created schedule's timezone defaults to that of the user's most recent
schedule (there is no per-user setting), else UTC; session: "this" pins the
caller's root session. agentMode (plan or build, the executor's modeId)
defaults to build on create and is not bounded by the caller: the permission
word is what bounds a run. The row records createdBy: {kind: "agent", deviceId,
workspaceId, sessionId, title}, the list and the editor show Created by an
agent in ‹session› linking to it, and each change is a schedule.create /
schedule.update / schedule.delete row in codeAudit with the session id
(refusals as <op>.refused with the code). A user's schedules look as before.
Scheduled chats share all of this. The same list, switch, limit, timetable and
history also run prompts in the chat itself, for every signed-in user, whether or
not the Agents panel is on for them: see Scheduled chats.
They are listed on the same page (/schedules) marked Chat or Agent, with
a filter All · Chats · Agents that starts on the side of the sidebar you came
from; a user without the Agents panel sees only chats and no filter. The row in
the sidebar carries a badge only while a schedule needs attention (its last run
failed, or it was switched off with a reason).
Limits and the switch. CHAT_SCHEDULES_MAX_PER_USER (default 20) caps a
user's schedules, of every kind. CHAT_SCHEDULES_ENABLED is on unless exactly
false. For coding-agent schedules it only matters where CODE_AGENTS_ENABLED=true:
a deployment that has turned on
remote agents has already accepted what agents can do, each schedule is
something a user built, and the default permission word is Ask. Off stops the
loop, answers the API 404 and hides the sidebar entry; schedules are kept, and
whatever came due meanwhile is judged by the downtime rule above rather than
replayed. Schedules are owner-only, erased with the account and moved on a merge.
The API is under /api/v2/code/schedules, so the 7-day sign-in rule covers it.
Driving agents from the chat¶
With /code on, a chat can hand work to a coding agent on one of the user's own computers. Four chat
tools do it, offered when /code is on for the deployment, the user holds code_agents and has at
least one paired computer:
agent_sessionslists the user's computers, their workspaces and recent sessions, each with its state (idle, working, needs-you, error, offline). Read-only.agent_startcreates a session on a computer and workspace, sets its mode, model and Plan / Ask / Auto / Allow word, and sends the prompt.agent_sendsends a prompt to an existing session. It is refused while the session is working or waiting on the user, and it never changes the session's own permission word.agent_readreturns a session's state, open approvals and questions and its last answer; withfullthe transcript, newest first and cut at a size limit. What it returns was written by the agent from repository content, so it is marked as untrusted outside text, like a fetched web page.
Nothing waits. agent_start and agent_send return as soon as the prompt is accepted; the chat
reads the result later with agent_read, when the user asks. The chat keeps a card for each
start or send (title, computer and workspace, live state, Open in /code).
Permission posture. agent_start and agent_send act on a computer, so they follow the chat's
tool-approval policy (the approval card on Ask; no card on always-allow); agent_sessions and
agent_read never ask. A scheduled chat run on Ask refuses the two acting tools, as it refuses any
tool that needs approval. A new session gets the project profile's permission word, or Ask when
there is no profile (or the model names a different computer or workspace than the profile's); the
fixed floor still applies. The approvals a running session raises appear in the Needs-you inbox,
as for any session. The computer pays with its own credential, as for scheduled runs.
Only the user's own computers. Every computer and session id is resolved against the caller's
devices; another user's id is refused as if it did not exist. An offline computer is refused with a
plain message. The tools follow the 7-day sign-in rule: older than that, they
refuse and tell the user to sign in again. Every start and send writes a /code audit row
(agent.start, agent.send) with the source chat id, never the prompt.
The project agent profile. Under a project's settings each user can name their own default
computer and workspace (and mode, model and permission word) for that project's chats. It is stored per
project and user (projectAgentProfiles), never on the project: a project can be shared, a computer is
one user's. The API is GET | PUT | DELETE /api/v2/projects/[id]/agent-profile, for the caller's own
profile and any user who can open the project; the computer and workspace are validated like a
schedule's, and the names shown are resolved by the server. The chat is told the project's default
target in its tool preprompt. A profile is erased with the project and with its user.
When it does not work¶
| Symptom | Where to look |
|---|---|
| no Agents switch in the sidebar | CODE_AGENTS_ENABLED is not exactly "true", or the user is not signed in |
/code answers 404 |
same flag; the route gates on it independently of the sidebar |
a computer never appears, even pending |
its bearer is failing local validation — check CODE_MACHINE_ISSUER/CODE_MACHINE_AUDIENCE/CODE_MACHINE_CLIENT_ID match what the enrollment minted, and that the computer's sub has signed into this chat at least once |
a computer appears pending forever |
its owner hasn't clicked Confirm yet; only the owner can, from their own signed-in panel |
| every call to a paired computer answers "not connected" | the computer's process is not running, or its WSS dial to this origin is failing (check its own logs) |
a computer that was working now gets 4401 closes |
its access token stopped renewing — re-run its enrollment |
| a schedule never fires | CHAT_SCHEDULES_ENABLED=false, or CODE_AGENTS_ENABLED is off; or its history says why (offline, skipped, switched off after three failures) |
| a terminal will not open | the deployment lacks CODE_TERMINAL_ENABLED=true, the computer was enrolled with --no-terminal (or before terminals were on by default), or the user's last sign-in is older than 7 days (which blocks all of /code, not only terminals) |
For developers¶
The wire protocol between the chat and galopin is agent/PROTOCOL.md.
Attachments: what a surface uploads, keeps and renders¶
The images and files a user sends an agent live in chat's attachment
store: the same GridFS bucket (files), the same writer, the same limits
and the same once-at-upload document extraction as a chat message. There is
no second store. Only the owner tag differs. A chat file is tagged with its
chat id; an agent file is tagged with an owner key:
deviceId is the codeDevices row (24 hex characters). sessionId is
whatever id your backend gives the session, [A-Za-z0-9_.:~-]{1,200}, and
nothing interprets it. The store itself (src/lib/server/files/attachmentStore.ts)
is surface-agnostic: any key of the form <surface>:<rest> works. A
chat id never contains a colon, so an owner key can never reach a
chat's files, and chat's routes can never reach an owner-keyed file.
Nothing here depends on the agent transport. The contract is only the key and
a messageId that you choose.
1. Upload before you send¶
Pick a messageId for the outgoing message (for example the client message
id you will hand the agent), then upload its files:
POST /api/v2/code/attachments/<urlencoded key>
multipart/form-data: messageId=<id>, files=<File> (repeat, at most 10)
→ superjson { files: MessageFile[] }
The rules:
- Limits. 10 MB per file (
MAX_ATTACHMENT_BYTES, chat's limit), which answers 413. The declared type must matchAGENT_ATTACHMENT_MIME_ALLOWLIST(chat's text and document lists, png/jpeg/gif/webp, and chat's long-paste typeapplication/vnd.chatui.clipboard, which a transport should treat astext/plain). Anything else answers 400. - Documents. PDFs and office files are extracted once, at upload, through
the gateway and billed to the caller, exactly as in chat. The returned
MessageFile.extracted.valueis the sha of the markdown. - Ownership. The key's device must be one of the caller's paired devices. Somebody else's device, or one that does not exist, answers 404; a pending device answers 409; a malformed key answers 400.
From the browser, uploadComposerFiles(endpoint, messageId, files) in
$lib/utils/composerFiles does this and throws the server's message on
refusal.
2. Keep the MessageFile references¶
Each MessageFile is {type: "hash", value: <sha256>, mime, name,
extracted?}, the same shape as a chat message's files. Put them on the
user message you render. After a reload you can get them back from either
side:
- Server (for example your bridge, when it replays a user frame):
findAttachments(key, messageId)fromattachmentStore.ts. It returns the sameMessageFile[], in upload order, with extracted text folded in. - Browser:
GET /api/v2/code/attachments/<key>?messageId=<id>, which returns superjson{ files: MessageFile[] }.
To hand bytes to the agent, the server calls readAttachment(sha, key),
which returns the base64 value and the sniffed mime.
3. Render them¶
ChatMessageColumn and ChatMessage take an optional fileBaseUrl, which
UploadedFile uses for user files as <fileBaseUrl>/<sha>. Pass:
That route (GET …/<key>/<sha256>) makes the same device check, then serves
the bytes with chat's headers: Content-Disposition: attachment and a
sandbox CSP. An <img> still displays it; opening it in a tab downloads it.
Leave the prop unset on chat. Chat's page-relative
/conversation/<id>/output/<sha> is the default and has not changed.
The composer pieces¶
Chat's attachment affordances are reusable pieces, and each takes your own MIME allowlist:
- picking:
ChatInput, withmimeTypesset and a bindablefiles. An emptymimeTypeshides the button, which is whatAgentComposerdoes today; - pasting:
pastedAttachments(clipboardData, {mimeTypes, directPaste}), which turns a long paste into a clipboard chip and filters pasted files; - dropping:
FileDrag($lib/utils/fileDrag.svelte) on<svelte:window ondragenter ondragleave>, plusFileDropzonewithbind:onDrag={drag.active}; - chips:
ComposerFileChips, with a bindablefiles.
ChatWindow wires the same pieces for chat.
Cleanup¶
DELETE /api/v2/code/devices?id= deletes every file under code:<deviceId>:
after it tombstones the row (deleteCodeDeviceAttachments). Deleting one
session's files is deleteAttachments(key). Nothing calls that yet, so call
it when your backend deletes a session. The deletions match on
metadata.conversation, never on the filename, and a prefix must end at a
:, so code:abc: never matches code:abcdef:….