Skip to content

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 codeDevices collection): 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 (session <id>)"), plus a one-line notice if it fails or waits for your approval. Typing in the delegate yourself is never reported. See <a href="../agent-machines-reference/#sessions-that-talk-to-sessions">Sessions that talk to sessions</a>.</td> </tr> <tr> <td>Delegating</td> <td>a <strong>Delegate profiles</strong> dialog in the computer's ··· menu; "Spawned …" cards for committee members, and one gathered report</td> <td><code>profiles.list</code> / <code>profiles.set</code> (above) and the <code>session_profiles</code> / <code>session_committee</code> tools. A committee is several <code>session_spawn</code>s through the same checks (one card per member under Ask), whose reports galopin gathers into one message to the spawner. See <a href="../agent-machines-reference/#delegating-work">Delegating work</a>.</td> </tr> <tr> <td>Images and files</td> <td>the chat's attach picker, dropping a file onto the composer, chips and rendering</td> <td>See <a href="#attachments-what-a-surface-uploads-keeps-and-renders">Attachments</a> below. Offered when the backend advertises <code>files</code> or <code>images</code>.</td> </tr> <tr> <td>Forks</td> <td>"Fork from here" on a finished assistant message</td> <td><code>POST v1/agents/:id/handoff</code>: 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 <code>chat-history.md</code> attachment — the dialog always carries it (a fork without the chat would just be a new session), while the route's <code>carry</code> flag stays optional. The new session is titled <code>Fork: …</code> (older forks: <code>Handoff: …</code>) and says where it came from.</td> </tr> <tr> <td>Retry and rollback</td> <td>↻ on an answer, or editing a prompt (when the computer reports <code>revert</code>)</td> <td><code>POST v1/agents/:id/revert {messageId}</code> rolls the session back to before that prompt (opencode <code>POST /session/:id/revert</code>), 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. <code>POST v1/agents/:id/unrevert</code> undoes it before the next prompt.</td> </tr> <tr> <td>Workspaces and worktrees</td> <td>path autocomplete in "Add workspace"; "New worktree…" on a git workspace</td> <td><code>workspace.suggest</code> (inside the computer's <code>workspaceRoots</code>, or <code>$HOME</code> with none) and <code>workspace.create {worktree}</code> (<code>git worktree add</code>, branch and base of the user's choosing); archiving a worktree workspace can also remove the worktree.</td> </tr> </tbody> </table> <div class="admonition warning"> <p class="admonition-title">What the coordination gates do not cover</p> </div> <p>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 <a href="../agent-machines-reference/#sessions-that-talk-to-sessions">Sessions that talk to sessions</a>.</p> <h3 id="tool-images-the-toolimages-capability">Tool images (the <code>toolImages</code> capability)<a class="headerlink" href="#tool-images-the-toolimages-capability" title="Permanent link">¶</a></h3> <p>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.</p> <ul> <li><strong>Only PNG, JPEG, GIF and WebP are shown, inline as images.</strong> SVG is never displayed inline, whatever the computer calls it.</li> <li><strong>The chat does not take the computer's word for the type.</strong> 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.</li> <li>Up to 8 images are shown per tool call. An image the agent saved as a file is the file explorer's, not this.</li> </ul> <h3 id="slash-commands-the-commands-capability">Slash commands (the <code>commands</code> capability)<a class="headerlink" href="#slash-commands-the-commands-capability" title="Permanent link">¶</a></h3> <p>The computer lists each workspace's slash commands (<code>backend.commands</code>): name, description, where the definition lives (the agent's own <code>init</code>/ <code>review</code> are <code>builtin</code>; a name only the workspace list reports is <code>project</code> — repository code, listed only when the computer was enrolled with <code>--allow-project-config</code>; 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 <code>@path</code> files it reads, and a hash of the template. <strong>The template itself never crosses the link</strong> — the snippets and the hash are what a user confirms against.</p> <p><code>session.command</code> runs one behind the computer's gates, each refused before anything runs and each audited in the computer's own <code>audit.log</code> as <code>command {name, origin, shell, decision}</code> — never the arguments, never the expanded text:</p> <ul> <li>unknown name → <code>not_found</code> (the list is re-read at run time, never trusted from an old menu);</li> <li>the panel's confirmation carried a <code>templateHash</code> that no longer matches → <code>conflict</code> (the definition changed since it was reviewed);</li> <li><code>commandShell: denied</code> (the default) and the template expands shell — or its shell behaviour is unknown (MCP prompts, ACP commands) → <code>forbidden</code>;</li> <li>an <code>@path</code> ref matching <code>fileDeny</code> → <code>forbidden</code>;</li> <li>a command pinned to a non-gateway model with <code>allowFreeModels</code> denied → <code>forbidden</code> (the same gate <code>session.setModel</code> applies);</li> <li>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 <code>subtask</code> command in that spot is refused outright (it would run unsupervised in the other agent).</li> </ul> <p>The capability is probed, never versioned: opencode advertises <code>commands</code> only when its own <code>GET /doc</code> lists the operation (cached per process, re-probed on restart). Cerea maps a 404 to "panel commands only".</p> <p>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 <code>localStorage</code> keyed by the template hash; a later run whose hash differs answers the 409 above and the sheet re-asks.</p> <p>A computer can run an ACP agent instead of opencode (<code>galopin run --backend acp --acp-command "<agent>"</code>): Gemini CLI, Claude Code or Codex through their ACP adapters, or <code>opencode acp</code>. Those report fewer capabilities (no usage, compaction or subagents), and the panel hides the matching controls.</p> <p>An ACP agent that can use an HTTP MCP server still gets galopin's coordination tools (<code>session_*</code>, <code>schedule_*</code>, 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 <code>--no-agent-tools</code> turns it off (see <a href="../agent-machines-reference/#coordination-tools-over-mcp">Coordination tools over MCP</a>).</p> <h2 id="computer-settings-and-delegate-profiles">Computer settings and delegate profiles<a class="headerlink" href="#computer-settings-and-delegate-profiles" title="Permanent link">¶</a></h2> <p>Each computer's row in the sidebar has a <strong>···</strong> button (<strong>Computer settings</strong>). Its one item, <strong>Profiles</strong>, opens the <strong>Delegate profiles</strong> 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 (<code>session_spawn</code>, or a committee through <code>session_committee</code>) 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 <a href="../agent-machines-reference/#delegate-profiles">Delegate profiles</a>.</p> <p>What the operator should know:</p> <ul> <li><strong>The computer owns the set.</strong> It is stored in <code>profiles.json</code> in galopin's state directory on the computer, not in the chat's database. The panel reads it with <code>profiles.list</code> and replaces it whole with <code>profiles.set</code>, 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 a <code>profiles.set {count, default}</code> row in the computer's <code>audit.log</code>.</li> <li><strong>Only the device's owner may call them</strong>, the same ownership check as every other <code>/code</code> route for a device, and the 7-day sign-in rule applies. They are under <code>/api/v2/code/</code>, so a stale sign-in answers <code>reauth_required</code>.</li> <li><strong>The dialog is read-only, and says why,</strong> for a computer that is offline, was enrolled with <code>--no-agent-tools</code>, or runs a galopin that predates profiles (the hello's <code>machine.capabilities.delegateProfiles</code> is absent). An older Cerea never sends the ops, so mixed versions degrade to "not available".</li> <li><strong>A model is not a permission.</strong> 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 <code>plan</code> mode of <code>session_spawn</code> / <code>session_committee</code>.</li> </ul> <h2 id="plan-ask-and-auto">Plan, Ask and Auto<a class="headerlink" href="#plan-ask-and-auto" title="Permanent link">¶</a></h2> <p><img alt="An Auto session: the reviewer's reason sits under each approval card that comes to you." src="../assets/screenshots/agents-auto-reviewer.webp" /></p> <p>The composer's permission control has four steps: <strong>Plan</strong> (the agent may only look and plan), <strong>Ask</strong> (every action that needs permission asks you first), <strong>Auto</strong> (each kind of action runs, is judged by your reviewer, or comes to you, as you set it) and <strong>Allow</strong> (hands off). On the wire they are the computer's <code>deny</code>, <code>ask</code> and <code>allow</code>; Auto is <code>ask</code> plus a per-class map (<code>autoClasses</code>: <code>run</code> or <code>ask</code> for each kind of action, <code>session.setPermissionMode</code>), the map's presence being what makes a session Auto, and Allow is <code>allow</code> with none. Cerea works the map out from your rows when you pick Auto; the browser never names it.</p> <p>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, <strong>your rows</strong> and a fixed floor. On <strong>Auto</strong>, a kind of action you set to <strong>Runs</strong> runs with no card. A kind you set to <strong>Reviewer</strong> or <strong>You</strong> 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. <strong>Allow</strong> is hands off: everything runs except the fixed floor (galopin's own state is refused; credential and secret paths, writes to <code>.git</code> 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 <strong>Workspace → Agents</strong>; your administrator only switches reviewers on for the deployment.</p> <p><strong>How the reviewer decides.</strong> 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 <strong>once</strong> 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 <code>.env</code>, and writes to CI configuration, and editor writes into a repository's <code>.git</code> 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.</p> <p><strong>Plain reads skip the reviewer.</strong> This is a noise and latency reduction, not a security measure: a harmless read such as <code>docker compose ps</code> 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 <code>|</code>, <code>;</code>, <code>&&</code> or <code>||</code>; no file redirects (only <code>>/dev/null</code>, <code>2>/dev/null</code>, <code>2>&1</code>), no substitutions, variables, wildcards or brace expansion, no subshells or functions; every program on a short read-only list (<code>cat</code>, <code>head</code>, <code>tail</code>, <code>wc</code>, non-recursive <code>grep</code>, <code>ls</code>, <code>ps</code>, <code>df</code>, <code>du</code>, <code>find</code> without <code>-exec</code>/<code>-delete</code>/<code>-fprint</code>, <code>sed</code> without <code>-i</code>, <code>git status|log|diff|show</code>, <code>docker ps|logs</code>, <code>docker compose ps|logs</code>, <code>systemctl status</code>, <code>journalctl</code>) 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, <code>.env</code> 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 (<code>env</code>, <code>docker inspect</code>, <code>docker compose config</code>) 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 <em>Never auto review requests</em> 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 <em>Allowed: read-only</em>, with no probability.</p> <p>Auto's rules are guardrails against a careless agent, not a security boundary; see ADR 0107 and ADR 0109.</p> <p><strong>What you see.</strong> An action the reviewer allowed shows <em>Auto-allowed by reviewer model</em> on its card; a plain read allowed by rule shows <em>Auto-allowed: read-only</em>. An ask it sent to you says why, above the buttons and in the notification: <em>The reviewer wasn't sure enough (51%; it allows at 90%)</em>, <em>This kind of request always comes to you: ⟨reason⟩</em>, or <em>The reviewer couldn't be reached, so this comes to you</em>. The line under the control adds <strong>Auto: reviewer unavailable</strong> 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.</p> <p>The reviewer calls the model with <strong>your own sign-in access</strong>, 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.</p> <p>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.</p> <p>To see what reaches you, tap the <strong>shield</strong> beside the control (its name carries the step, for example "Permissions: Auto"). The sheet opens with one sentence for the selected step, for example <em>Auto on studio-laptop: edits, web, agents and tools run · commands and outside access are reviewed · secrets and loops come to you</em>. 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.</p> <p><strong>Never auto review requests</strong> 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.</p> <p><strong>Pairing sets no limits.</strong> The Pair-a-machine dialog has no limits table and prints no <code>--limit</code>; 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 <em>Stopped: the agent was repeating the same action</em> as a plain line, not as an error, when the agent is cut off.</p> <p>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.</p> <h2 id="the-review-log">The review log<a class="headerlink" href="#the-review-log" title="Permanent link">¶</a></h2> <p>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 <strong>Settings → Review log</strong> (the link is also in a session's Permissions sheet); an administrator sees everyone's under <strong>Administration → Agents → Review log</strong>. 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.</p> <h2 id="connectors-for-an-agent">Connectors for an agent<a class="headerlink" href="#connectors-for-an-agent" title="Permanent link">¶</a></h2> <p>A coding session can use your <strong>connectors</strong>: the ones in Workspace → Connectors (your own and your administrator's) and the servers of your own computers (<a href="../local-connectors/">local connectors</a>). <strong>Off by default</strong>: a session gets none until you switch them on, so it carries only what it needs.</p> <ul> <li><strong>For one session</strong>: open the shield beside the permission step (the session's Permissions sheet) and use the <strong>Connectors</strong> 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".</li> <li><strong>As a project default</strong>: under the project's <strong>Agent workspace</strong>, 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.</li> </ul> <p>The agent has two tools: <code>connector_list</code> (what is on, with each tool) and <code>connector_call</code> (one tool of one connector). Cerea runs a call to a Cerea connector <strong>with your own sign-in or key</strong>, 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.</p> <p>A connector call is a <strong>tool</strong> call like any other connector tool: a local <code>--deny tools</code> 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 <em>tools</em>, and the <a href="#plan-ask-and-auto">review log</a> 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.</p> <h2 id="where-an-agents-asks-show-up">Where an agent's asks show up<a class="headerlink" href="#where-an-agents-asks-show-up" title="Permanent link">¶</a></h2> <p>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:</p> <ul> <li><strong>On /code</strong>, at the top of the page.</li> <li><strong>On every other page</strong>, 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).</li> <li><strong>Above the composer of a chat that started or prompted agent sessions</strong>: 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).</li> </ul> <p>The <strong>Agents</strong> 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.</p> <h2 id="notifications-for-agents">Notifications for agents<a class="headerlink" href="#notifications-for-agents" title="Permanent link">¶</a></h2> <p>With Web Push set up (<a href="../notifications/#for-operators">Notifications</a>: the <code>CHAT_VAPID_*</code> keys, which the kit's <code>./configure</code> creates once), users who turned notifications on are told about their agents even with the tab closed:</p> <table> <thead> <tr> <th>Event</th> <th>When</th> </tr> </thead> <tbody> <tr> <td>An agent needs you</td> <td>a session asks for an approval or a question (the cards the Needs-you inbox lists)</td> </tr> <tr> <td>A long agent turn finishes</td> <td>a root session goes from busy to idle after more than a minute and a half (error if failed)</td> </tr> <tr> <td>A scheduled run finishes</td> <td>the scheduler's own outcome (sent, failed, or computer offline), in place of the turn event</td> </tr> <tr> <td>A background task fails</td> <td>a task an agent started in the background ended with an error; a Stop is not a failure</td> </tr> </tbody> </table> <p>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 <strong>Settings, Notifications</strong>, 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.</p> <h2 id="scheduled-actions">Scheduled actions<a class="headerlink" href="#scheduled-actions" title="Permanent link">¶</a></h2> <p>Users can put a prompt on a timetable: <strong>Schedules</strong>, at the foot of the sidebar (the same row in Chats and Agents; <code>/code?view=schedules</code> redirects to <code>/schedules?kind=agent</code>). 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 <strong>stop after a number of runs</strong>: 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.</p> <p><strong>What runs where.</strong> 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 <em>before</em> it runs anything. The run itself is the panel's own operations over the computer link: create a session, set its Plan (<code>deny</code>) / Ask / Auto / Allow word, (optionally) grant its coordination tools, send the prompt. <strong>Nothing runs in the chat</strong>; the session is the computer's, and it pays for the run with its own credential, as for any other prompt.</p> <p>Every scheduled prompt starts with one line the user did not write: <code>[Scheduled run of "<name>", <timetable> (<timezone>); previous run <3 h ago | none>; coordination: <keys granted | none>]</code>, 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 <a href="../agent-machines-reference/#the-delegation-skill">delegation skill</a> tells the model how to read that line (<code>src/lib/server/schedules/runHeader.ts</code> writes it).</p> <p><strong>Rules the operator should know.</strong></p> <table> <thead> <tr> <th>Situation</th> <th>What is recorded</th> </tr> </thead> <tbody> <tr> <td>the computer is offline at run time</td> <td><code>missed-offline</code>; nothing is queued</td> </tr> <tr> <td>the computer was revoked</td> <td>the schedule is switched off, with the reason shown on it</td> </tr> <tr> <td>the previous run's session is still working</td> <td><code>skipped-still-running</code></td> </tr> <tr> <td>the workspace or pinned session is gone</td> <td><code>failed</code> with that reason; three failures in a row switch the schedule off</td> </tr> <tr> <td>the chat was down when a run was due</td> <td>one catch-up if it is under half the interval late (at most an hour); otherwise <code>missed-downtime</code></td> </tr> </tbody> </table> <p>Every occurrence, fired or not, is a row in <code>scheduleRuns</code>: that is the history users see per schedule and the audit record. Rows are kept 90 days.</p> <p><strong>The Ask warning.</strong> Unattended, a session on <strong>Ask</strong> stops at its first approval and waits. The approval card appears in the Needs-you inbox. The editor says so beside the selector. <strong>Allow</strong> is for work that must finish on its own, and the fixed floor still comes to you (credential files, <code>.git</code> and CI writes) and a local <code>--deny</code> still refuses.</p> <p><strong>Working with other sessions.</strong> Two options in the editor, both off: <strong>Can find, read and message other sessions</strong> (<code>session_list</code>, <code>session_read</code>, <code>session_send</code>) and <strong>Can start new sessions</strong> (<code>session_spawn</code>). 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 <code>session.grantCoordination {sessionId, keys}</code> (see <a href="../agent-machines-reference/#granting-a-session-its-coordination-tools">Granting a session its coordination tools</a>). The grant is the computer's to limit: a key it denies locally (<code>--deny agents</code>) 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 <code>--no-agent-tools</code> 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 (<code>keys: []</code>).</p> <p><strong>Schedules made by agents.</strong> A coding session can list, create, update, pause and delete schedules on its own computer through galopin's schedule tools (see <a href="../agent-machines-reference/#scheduling-work">Scheduling work</a> for when those ask). They are <code>call</code> frames on the computer link, answered by <code>server/code/machineCalls.ts</code> as the computer's owner: Cerea's <code>welcome</code> advertises <code>features.machineCalls: ["schedule"]</code>, and a galopin with the tools reports <code>scheduleTools</code> among its backend capabilities. The ops are <code>schedule.context</code> (is the caller's session, or its root, a run of a schedule: looked up in the run records), <code>schedule.list</code>, <code>schedule.create</code>, <code>schedule.update</code> and <code>schedule.delete</code>. On top of the store's rules (floor, cap, timezone, switch), Cerea refuses:</p> <table> <thead> <tr> <th>Refused</th> <th>Answer</th> </tr> </thead> <tbody> <tr> <td>another computer as the target, or a workspace not on this one</td> <td><code>invalid</code></td> </tr> <tr> <td>a permission mode looser than the calling session's (deny < ask < allow)</td> <td><code>forbidden</code></td> </tr> <tr> <td>coordination the calling session does not have itself</td> <td><code>forbidden</code></td> </tr> <tr> <td>a sixth running agent-made schedule on the computer</td> <td><code>limit</code></td> </tr> <tr> <td>an eleventh change in an hour from the computer (pause and delete exempt)</td> <td><code>limit</code></td> </tr> <tr> <td>updating or deleting another computer's schedule</td> <td><code>not_found</code></td> </tr> <tr> <td>anything but <code>schedule.context</code> while <code>CHAT_SCHEDULES_ENABLED=false</code></td> <td><code>unavailable</code></td> </tr> </tbody> </table> <p>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; <code>session: "this"</code> pins the caller's root session. <code>agentMode</code> (<code>plan</code> or <code>build</code>, the executor's <code>modeId</code>) defaults to <code>build</code> on create and is not bounded by the caller: the permission word is what bounds a run. The row records <code>createdBy: {kind: "agent", deviceId, workspaceId, sessionId, title}</code>, the list and the editor show <strong>Created by an agent in ‹session›</strong> linking to it, and each change is a <code>schedule.create</code> / <code>schedule.update</code> / <code>schedule.delete</code> row in <code>codeAudit</code> with the session id (refusals as <code><op>.refused</code> with the code). A user's schedules look as before.</p> <p><strong>Scheduled chats share all of this.</strong> 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 <a href="../chat-admin/#scheduled-chats">Scheduled chats</a>. They are listed on the same page (<code>/schedules</code>) marked <strong>Chat</strong> or <strong>Agent</strong>, with a filter <strong>All · Chats · Agents</strong> 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).</p> <p><strong>Limits and the switch.</strong> <code>CHAT_SCHEDULES_MAX_PER_USER</code> (default 20) caps a user's schedules, of every kind. <code>CHAT_SCHEDULES_ENABLED</code> is on unless exactly <code>false</code>. For coding-agent schedules it only matters where <code>CODE_AGENTS_ENABLED=true</code>: 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 <code>/api/v2/code/schedules</code>, so the 7-day sign-in rule covers it.</p> <h2 id="driving-agents-from-the-chat">Driving agents from the chat<a class="headerlink" href="#driving-agents-from-the-chat" title="Permanent link">¶</a></h2> <p>With <code>/code</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>/code</code> is on for the deployment, the user holds <code>code_agents</code> and has at least one paired computer:</p> <ul> <li><strong><code>agent_sessions</code></strong> lists the user's computers, their workspaces and recent sessions, each with its state (idle, working, needs-you, error, offline). Read-only.</li> <li><strong><code>agent_start</code></strong> creates a session on a computer and workspace, sets its mode, model and Plan / Ask / Auto / Allow word, and sends the prompt.</li> <li><strong><code>agent_send</code></strong> sends 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.</li> <li><strong><code>agent_read</code></strong> returns a session's state, open approvals and questions and its last answer; with <code>full</code> the 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.</li> </ul> <p><strong>Nothing waits.</strong> <code>agent_start</code> and <code>agent_send</code> return as soon as the prompt is accepted; the chat reads the result later with <code>agent_read</code>, when the user asks. The chat keeps a card for each start or send (title, computer and workspace, live state, <strong>Open in /code</strong>).</p> <p><strong>Permission posture.</strong> <code>agent_start</code> and <code>agent_send</code> act on a computer, so they follow the chat's tool-approval policy (the approval card on Ask; no card on always-allow); <code>agent_sessions</code> and <code>agent_read</code> 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 <strong>Ask</strong> 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.</p> <p><strong>Only the user's own computers.</strong> 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 <a href="#the-7-day-sign-in">7-day sign-in</a> rule: older than that, they refuse and tell the user to sign in again. Every start and send writes a <code>/code</code> audit row (<code>agent.start</code>, <code>agent.send</code>) with the source chat id, never the prompt.</p> <p><strong>The project agent profile.</strong> 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 (<code>projectAgentProfiles</code>), never on the project: a project can be shared, a computer is one user's. The API is <code>GET | PUT | DELETE /api/v2/projects/[id]/agent-profile</code>, 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.</p> <h2 id="when-it-does-not-work">When it does not work<a class="headerlink" href="#when-it-does-not-work" title="Permanent link">¶</a></h2> <table> <thead> <tr> <th>Symptom</th> <th>Where to look</th> </tr> </thead> <tbody> <tr> <td>no Agents switch in the sidebar</td> <td><code>CODE_AGENTS_ENABLED</code> is not exactly <code>"true"</code>, or the user is not signed in</td> </tr> <tr> <td><code>/code</code> answers 404</td> <td>same flag; the route gates on it independently of the sidebar</td> </tr> <tr> <td>a computer never appears, even <code>pending</code></td> <td>its bearer is failing local validation — check <code>CODE_MACHINE_ISSUER</code>/<code>CODE_MACHINE_AUDIENCE</code>/<code>CODE_MACHINE_CLIENT_ID</code> match what the enrollment minted, and that the computer's <code>sub</code> has signed into this chat at least once</td> </tr> <tr> <td>a computer appears <code>pending</code> forever</td> <td>its owner hasn't clicked <strong>Confirm</strong> yet; only the owner can, from their own signed-in panel</td> </tr> <tr> <td>every call to a paired computer answers "not connected"</td> <td>the computer's process is not running, or its WSS dial to this origin is failing (check its own logs)</td> </tr> <tr> <td>a computer that was working now gets <code>4401</code> closes</td> <td>its access token stopped renewing — re-run its enrollment</td> </tr> <tr> <td>a schedule never fires</td> <td><code>CHAT_SCHEDULES_ENABLED=false</code>, or <code>CODE_AGENTS_ENABLED</code> is off; or its history says why (offline, skipped, switched off after three failures)</td> </tr> <tr> <td>a terminal will not open</td> <td>the deployment lacks <code>CODE_TERMINAL_ENABLED=true</code>, the computer was enrolled with <code>--no-terminal</code> (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)</td> </tr> </tbody> </table> <h2 id="for-developers">For developers<a class="headerlink" href="#for-developers" title="Permanent link">¶</a></h2> <p>The wire protocol between the chat and galopin is <code>agent/PROTOCOL.md</code>.</p> <h3 id="attachments-what-a-surface-uploads-keeps-and-renders">Attachments: what a surface uploads, keeps and renders<a class="headerlink" href="#attachments-what-a-surface-uploads-keeps-and-renders" title="Permanent link">¶</a></h3> <p>The images and files a user sends an agent live in <strong>chat's attachment store</strong>: the same GridFS bucket (<code>files</code>), 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 <strong>owner key</strong>:</p> <div class="highlight"><pre><span></span><code><a id="__codelineno-1-1" name="__codelineno-1-1" href="#__codelineno-1-1"></a>code:<deviceId>:<sessionId> </code></pre></div> <p><code>deviceId</code> is the <code>codeDevices</code> row (24 hex characters). <code>sessionId</code> is whatever id your backend gives the session, <code>[A-Za-z0-9_.:~-]{1,200}</code>, and nothing interprets it. The store itself (<code>src/lib/server/files/attachmentStore.ts</code>) is surface-agnostic: any key of the form <code><surface>:<rest></code> 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.</p> <p>Nothing here depends on the agent transport. The contract is only the key and a <code>messageId</code> that you choose.</p> <h4 id="1-upload-before-you-send">1. Upload before you send<a class="headerlink" href="#1-upload-before-you-send" title="Permanent link">¶</a></h4> <p>Pick a <code>messageId</code> for the outgoing message (for example the client message id you will hand the agent), then upload its files:</p> <div class="highlight"><pre><span></span><code><a id="__codelineno-2-1" name="__codelineno-2-1" href="#__codelineno-2-1"></a>POST /api/v2/code/attachments/<urlencoded key> <a id="__codelineno-2-2" name="__codelineno-2-2" href="#__codelineno-2-2"></a>multipart/form-data: messageId=<id>, files=<File> (repeat, at most 10) <a id="__codelineno-2-3" name="__codelineno-2-3" href="#__codelineno-2-3"></a>→ superjson { files: MessageFile[] } </code></pre></div> <p>The rules:</p> <ul> <li><strong>Limits.</strong> 10 MB per file (<code>MAX_ATTACHMENT_BYTES</code>, chat's limit), which answers 413. The declared type must match <code>AGENT_ATTACHMENT_MIME_ALLOWLIST</code> (chat's text and document lists, png/jpeg/gif/webp, and chat's long-paste type <code>application/vnd.chatui.clipboard</code>, which a transport should treat as <code>text/plain</code>). Anything else answers 400.</li> <li><strong>Documents.</strong> PDFs and office files are extracted once, at upload, through the gateway and billed to the caller, exactly as in chat. The returned <code>MessageFile.extracted.value</code> is the sha of the markdown.</li> <li><strong>Ownership.</strong> The key's device must be one of the <strong>caller's paired</strong> devices. Somebody else's device, or one that does not exist, answers 404; a pending device answers 409; a malformed key answers 400.</li> </ul> <p>From the browser, <code>uploadComposerFiles(endpoint, messageId, files)</code> in <code>$lib/utils/composerFiles</code> does this and throws the server's message on refusal.</p> <h4 id="2-keep-the-messagefile-references">2. Keep the <code>MessageFile</code> references<a class="headerlink" href="#2-keep-the-messagefile-references" title="Permanent link">¶</a></h4> <p>Each <code>MessageFile</code> is <code>{type: "hash", value: <sha256>, mime, name, extracted?}</code>, the same shape as a chat message's <code>files</code>. Put them on the user message you render. After a reload you can get them back from either side:</p> <ul> <li><strong>Server</strong> (for example your bridge, when it replays a user frame): <code>findAttachments(key, messageId)</code> from <code>attachmentStore.ts</code>. It returns the same <code>MessageFile[]</code>, in upload order, with extracted text folded in.</li> <li><strong>Browser</strong>: <code>GET /api/v2/code/attachments/<key>?messageId=<id></code>, which returns superjson <code>{ files: MessageFile[] }</code>.</li> </ul> <p>To hand bytes to the agent, the server calls <code>readAttachment(sha, key)</code>, which returns the base64 value and the sniffed mime.</p> <h4 id="3-render-them">3. Render them<a class="headerlink" href="#3-render-them" title="Permanent link">¶</a></h4> <p><code>ChatMessageColumn</code> and <code>ChatMessage</code> take an optional <code>fileBaseUrl</code>, which <code>UploadedFile</code> uses for user files as <code><fileBaseUrl>/<sha></code>. Pass:</p> <div class="highlight"><pre><span></span><code><a id="__codelineno-3-1" name="__codelineno-3-1" href="#__codelineno-3-1"></a><span class="nx">fileBaseUrl</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="sb">`</span><span class="si">${</span><span class="nx">base</span><span class="si">}</span><span class="sb">/api/v2/code/attachments/</span><span class="si">${</span><span class="nb">encodeURIComponent</span><span class="p">(</span><span class="nx">key</span><span class="p">)</span><span class="si">}</span><span class="sb">`</span><span class="p">;</span> </code></pre></div> <p>That route (<code>GET …/<key>/<sha256></code>) makes the same device check, then serves the bytes with chat's headers: <code>Content-Disposition: attachment</code> and a sandbox CSP. An <code><img></code> still displays it; opening it in a tab downloads it. Leave the prop unset on chat. Chat's page-relative <code>/conversation/<id>/output/<sha></code> is the default and has not changed.</p> <h4 id="the-composer-pieces">The composer pieces<a class="headerlink" href="#the-composer-pieces" title="Permanent link">¶</a></h4> <p>Chat's attachment affordances are reusable pieces, and each takes your own MIME allowlist:</p> <ul> <li><strong>picking</strong>: <code>ChatInput</code>, with <code>mimeTypes</code> set and a bindable <code>files</code>. An empty <code>mimeTypes</code> hides the button, which is what <code>AgentComposer</code> does today;</li> <li><strong>pasting</strong>: <code>pastedAttachments(clipboardData, {mimeTypes, directPaste})</code>, which turns a long paste into a clipboard chip and filters pasted files;</li> <li><strong>dropping</strong>: <code>FileDrag</code> (<code>$lib/utils/fileDrag.svelte</code>) on <code><svelte:window ondragenter ondragleave></code>, plus <code>FileDropzone</code> with <code>bind:onDrag={drag.active}</code>;</li> <li><strong>chips</strong>: <code>ComposerFileChips</code>, with a bindable <code>files</code>.</li> </ul> <p><code>ChatWindow</code> wires the same pieces for chat.</p> <h4 id="cleanup">Cleanup<a class="headerlink" href="#cleanup" title="Permanent link">¶</a></h4> <p><code>DELETE /api/v2/code/devices?id=</code> deletes every file under <code>code:<deviceId>:</code> after it tombstones the row (<code>deleteCodeDeviceAttachments</code>). Deleting one session's files is <code>deleteAttachments(key)</code>. Nothing calls that yet, so call it when your backend deletes a session. The deletions match on <code>metadata.conversation</code>, never on the filename, and a prefix must end at a <code>:</code>, so <code>code:abc:</code> never matches <code>code:abcdef:…</code>.</p> </article> </div> <script>var target=document.getElementById(location.hash.slice(1));target&&target.name&&(target.checked=target.name.startsWith("__tabbed_"))</script> </div> <button type="button" class="md-top md-icon" data-md-component="top" hidden> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M13 20h-2V8l-5.5 5.5-1.42-1.42L12 4.16l7.92 7.92-1.42 1.42L13 8z"/></svg> Back to top </button> </main> <footer class="md-footer"> <nav class="md-footer__inner md-grid" aria-label="Footer" > <a href="../chat-admin/" class="md-footer__link md-footer__link--prev" aria-label="Previous: Administering the chat"> <div class="md-footer__button md-icon"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20 11v2H8l5.5 5.5-1.42 1.42L4.16 12l7.92-7.92L13.5 5.5 8 11z"/></svg> </div> <div class="md-footer__title"> <span class="md-footer__direction"> Previous </span> <div class="md-ellipsis"> Administering the chat </div> </div> </a> <a href="../reference/" class="md-footer__link md-footer__link--next" aria-label="Next: Reference"> <div class="md-footer__title"> <span class="md-footer__direction"> Next </span> <div class="md-ellipsis"> Reference </div> </div> <div class="md-footer__button md-icon"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M4 11v2h12l-5.5 5.5 1.42 1.42L19.84 12l-7.92-7.92L10.5 5.5 16 11z"/></svg> </div> </a> </nav> <div class="md-footer-meta md-typeset"> <div class="md-footer-meta__inner md-grid"> <div class="md-copyright"> <div class="md-copyright__highlight"> Copyright © Paolo Viviani — <a href="https://github.com/paoloviviani/Cerea/blob/main/LICENSE">Apache-2.0</a> </div> Made with <a href="https://squidfunk.github.io/mkdocs-material/" target="_blank" rel="noopener"> Material for MkDocs </a> </div> </div> </div> </footer> </div> <div class="md-dialog" data-md-component="dialog"> <div class="md-dialog__inner md-typeset"></div> </div> <script id="__config" type="application/json">{"annotate": null, "base": "..", "features": ["content.code.copy", "navigation.footer", "navigation.indexes", "navigation.sections", "navigation.tabs", "navigation.top", "navigation.tracking", "search.highlight", "search.suggest", "toc.follow"], "search": "../assets/javascripts/workers/search.2c215733.min.js", "tags": null, "translations": {"clipboard.copied": "Copied to clipboard", "clipboard.copy": "Copy to clipboard", "search.result.more.one": "1 more on this page", "search.result.more.other": "# more on this page", "search.result.none": "No matching documents", "search.result.one": "1 matching document", "search.result.other": "# matching documents", "search.result.placeholder": "Type to start searching", "search.result.term.missing": "Missing", "select.version": "Select version"}, "version": null}</script> <script src="../assets/javascripts/bundle.d7400e89.min.js"></script> <script src="../assets/agent-machines-redirect.js"></script> </body> </html>