The fixed points between the four components. Change one only by editing this file on main first.
| Item | Value |
|---|---|
| Desktop user | user (uid 1000), XFCE session on real Xorg display :0, xserver-xorg-video-dummy, 1280×800. Passwordless sudo since v10 (/etc/sudoers.d/90-cocoonbox-user, which keeps the proxy variables) |
| Agent user | cbagent (system user, no login shell, not in user’s groups) |
| VNC | TigerVNC 1.13 Xorg module (libvnc, Load "vnc") inside the desktop’s Xorg (cocoonbox-xorg.service) on 127.0.0.1:5900, SecurityTypes None, AlwaysShared, loopback only. It offers ContinuousUpdates (-313), Fence (-312) and ExtendedClipboard (UTF-8). With the defaults SendPrimary and SetPrimary it sends each PRIMARY and CLIPBOARD change to clients (unsolicited, to a client whose caps take text) and takes both selections when a client announces its clipboard, so Shift+Insert in a terminal pastes the client’s text. A client that advertises Fence must answer the server’s fence requests, or updates stop |
| Screen executor | cua-driver (from loomhand/cua, in the shuttle archive image/desk/shuttle.sh pins) runs only as the child of shuttle’s daemon (shuttle serve /run/cocoonbox/desk.sock, the user unit shuttle.service, enabled for every user) on /run/cocoonbox/driver.sock with --no-overlay. The cua-driver unit is masked for every user (/etc/systemd/user/cua-driver.service links to /dev/null), so a wants link in a restored home cannot start it, and the desk provision removes a template’s own driver, unit, autostart entry and session script. At every session start the autostart entry cocoonbox-shuttle.desktop imports the session’s SESSION_MANAGER without its host parts into the user manager, adds ICE auth entries for that form, and restarts shuttle, so the apps its driver launches register with xfce4-session also after cocoonbox-hostname renames the host, are saved by desktop_save and come back with the session; LibreOffice’s launcher drops it (/usr/lib/libreoffice/program/ooenv), since its gen VCL plugin crashes at start with it set. The agent has no driver client of its own: its computer tools run on shuttle’s computer verbs |
| Browser | Google Chrome launched by the toolhost for user, CDP on 127.0.0.1:9222, profile /home/user/.config/cocoonbox-chrome, with --restore-last-session: it reopens the tabs of its last clean exit, which the quiesced close (Browser.close) is. That close records in the profile (cocoonbox-was-running) whether Chrome was running, so the state layer carries it, and after a restore the agent starts Chrome only when the restored profile has the record. The managed policy /etc/opt/chrome/policies/managed/cocoonbox.json sets PasswordManagerEnabled=false, so Chrome never offers to save a password, and MetricsReportingEnabled=false and PromotionsEnabled=false, which drop the Welcome dialog and the Sign in to Chrome window of a new profile. The recommended policy /etc/opt/chrome/policies/recommended/cocoonbox.json sets RestoreOnStartup=1, so a new profile has no Continue where you left off infobar |
| Desktop session save | The toolhost call desktop_save runs /usr/local/lib/cocoonbox/session-save as user. It checkpoints xfce4-session over DBus (org.xfce.Session.Manager.Checkpoint), waits for the session file, and records whether Mousepad is open. A quiesce calls it before closing Chrome; a failure is logged and the save goes on. xfce4-session keeps its saves in ~/.local/state/xfce4-session (~/.cache/sessions links there, so the state layer carries them). On each session start, desktop-session adopts the newest saved session under the current hostname, xfce4-session restarts the saved clients (xfce4-terminal with its tabs and directories, Thunar windows), and an autostart entry reopens Mousepad when it was open (dconf default session-restore='always', so it reopens its documents) |
| Packages | ~/.config/cocoonbox/packages: one package per line, the packages marked manual beyond /etc/cocoonbox/packages.baseline (the image’s apt-mark showmanual, written at provision). An apt DPkg::Post-Invoke hook rewrites it as user after every apt run, so a removed package drops out. cocoonbox-packages.service (root oneshot, pulled in by cocoonbox-session.service, so it runs on every desktop start including after a restore) installs the listed packages this machine lacks, one at a time after a failed bulk install, and logs to the journal. It never orders the desktop behind it (Wants= only), checks with dpkg-query alone and runs apt-get update only when something is missing. A dpkg lock held past 120 s exits 75, and the unit retries every 60 s; a package that fails to install fails the unit without a retry. Packages the user removed from the image baseline come back with a new image, and packages from apt sources the user added fail, because /etc is not carried |
| Office apps | The image carries LibreOffice Calc, Writer and Impress with libreoffice-gtk3, since the gen VCL plugin a plain apt install libreoffice-calc runs has no accessibility tree; LibreOffice’s launcher drops SESSION_MANAGER (/usr/lib/libreoffice/program/ooenv). Office files open in LibreOffice, and /etc/xdg/mimeapps.list keeps text/plain with Mousepad |
| Agent binary | /usr/local/bin/cocoonbox-agent (one binary, two subcommands) |
| Loop | cocoonbox-agent serve, unit cocoonbox-agent.service, User=cbagent, EnvironmentFile=/etc/cocoonbox/agent.env (root:cbagent 0640), state /var/lib/cocoonbox (cbagent 0700) |
| Tool host | cocoonbox-agent toolhost-service, shuttle’s service plugin (spec /etc/cocoonbox/tools.d/cocoonbox.json), a child of shuttle serve as user with the session env (DISPLAY=:0, XAUTHORITY=/run/cocoonbox-x/Xauthority, DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus), on /run/cocoonbox/services/cocoonbox.sock (0700 directory); runs the shell, file, browser and state tools and relays the desktop tools to shuttle at SHUTTLE_SOCKET. Every toolhost call reaches it through shuttle’s /run/cocoonbox/desk.sock (user:cbagent 0660) in /run/cocoonbox (user:cbagent 2750; the setgid bit gives the socket group cbagent) |
| Desktop restart | cbagent may start, stop and restart exactly cocoonbox-session.service (the XFCE session of user on :0) and nothing else, through polkit: /etc/polkit-1/rules.d/60-cocoonbox-cbagent.rules allows org.freedesktop.systemd1.manage-units for that unit and those verbs to subject user cbagent. serve calls plain systemctl stop\|start cocoonbox-session.service. Xorg and VNC (cocoonbox-xorg.service) stay up. State.Restore closes Chrome through the toolhost (CDP), stops the session, swaps /home/user, starts the session and relaunches Chrome. When the session stops, logind ends whatever user launched inside it (KillUserProcesses=yes for user), so a desktop restart kills user-launched programs in the XFCE session; the agent’s tools and Chrome run in toolhost and user@1000 and are unaffected |
| Box identity | /run/cocoonbox-identity/identity (tmpfs; directory root 0755, file root 0644), written atomically (temp file and rename in that directory) by the api on every boot and wake: {"box_id":"cb-…","wake_id":"<random>","key_id":"<first 16 hex of sha256(public key)>","public_key":"<ed25519 key, base64url without padding>","name":"<box name>"}. cocoonbox-hostname.path sets the hostname to the name with _ as - (to the box id without a name); the X cookie holds for any hostname. The agent refuses the file unless the file and its directory belong to root and are not group- or world-writable |
| Workspace | /workspace, owned by user |
| Box API keys | The api sets the guest-visible claim env entries COCOONBOX_VAULT (the vault’s keys) and COCOONBOX_CONNECT (Connect’s injected grants) to records separated by spaces: NAME=placeholder;host,host;how. NAME and placeholder are A-Za-z0-9_-. The hosts are empty for a record that only sets a variable (LINK_NO_REFRESH=1;;). how is Header: template, with {} where the placeholder goes (Authorization: Bearer {}, Authorization: Basic {}: for a key sent as Basic), or ?param={} for a query key, with %, space and ; written %25, %20 and %3B. On every change of /run/silkd.env, cocoonbox-vault.path runs /usr/local/lib/cocoonbox/vault, which renames into /run/cocoonbox-vault (root, 0755; files 0644): vault.sh (export NAME='placeholder' per record, then unset NAME for each name in names that no record carries now), names (every name exported since boot) and keys (the records with hosts, one per line; absent without one). Login shells source vault.sh through /etc/profile.d/cocoonbox-vault.sh; the toolhost’s shell sources it before every command; the box agent reads keys at the start of each turn and lists each key, its hosts and how it is sent in its system prompt |
| Journal | persistent under /var/log/journal, one directory per machine id. Every clone and every wake gets a new /etc/machine-id; cocoonbox-machine-id.path restarts journald so it writes under the current one. journalctl shows the current machine id only; the history across wakes needs journalctl -m |
| Agent listen | 127.0.0.1:7443, plaintext HTTP/2 (h2c) gRPC, reached only through the sandboxd port relay |
| Desk runtime (cbcore) | /opt/desk/AGENTS.md (the guide; the agent’s gate), /opt/desk/lib/desk.mts (the desk API: shuttle’s endpoint for the desktop and for models), /opt/desk/lib/gui_plan.mts and /opt/desk/lib/png.mts on Node 24, installed from the shuttle release archive that image/desk/shuttle.sh pins by tag and sha256; /usr/local/bin/desk runs cocoonbox-agent desk VERB [key=value ...] as a shell front to the same desk calls; the desk API and desk reach shuttle at SHUTTLE_SOCKET, /run/cocoonbox/desk.sock, which shuttle hands to its runs, plugins and services (so to the toolhost, and through it to the agent’s shell, exec and scripts) and the image sets for the XFCE session by the systemd drop-in 20-desk.conf and for user’s systemd manager (shuttle, so the apps its driver launches, and D-Bus-activated apps) by /etc/environment.d/20-desk.conf; serve reaches the toolhost at /run/cocoonbox/desk.sock (TOOLHOST_SOCKET, which defaults to SHUTTLE_SOCKET), where shuttle splices the toolhost’s calls to its service; /etc/cocoonbox/desk.env (root 0644) holds GW_BASE_URL, copied from agent.env at provision. With SHUTTLE_SOCKET from shuttle, the toolhost relays desk verbs, the desk tool and model calls to shuttle as they come, and runs gui_plan, script_run and plugin tools on shuttle with its own result shape; shuttle’s runs reach shuttle at SHUTTLE_SOCKET and answer model calls with /etc/cocoonbox/shuttle.toml (gw from desk.env, the key unprovisioned, dated jev-1.14 builds only, safety indicator, confirm and hotkey off, plugins from /etc/cocoonbox/tools.d, no driver overlay, the endpoint 0660 to group cbagent), which shuttle-config copies to ~/.config/loomhand/shuttle/config.toml (0600) before each start, with ~/.config/loomhand/shuttle/flows a link to /workspace/flows; serve’s drop-in sets SHUTTLE_SOCKET too, so serve sends desk calls and runs there itself and the toolhost takes and keeps their screenshots (run_screenshot). cbagent gains no right by it: the toolhost socket it already opens runs any shell command as user. With the service spec /etc/cocoonbox/tools.d/cocoonbox.json, the toolhost runs as shuttle’s service plugin: shuttle starts cocoonbox-agent toolhost-service in its own session environment (the spec goes in place after the agent step, and an agent without that subcommand exits, so it never serves there) (so Chrome starts there too), hands it SHUTTLE_SERVICE_SOCKET to bind and SHUTTLE_SOCKET (its own endpoint) for desk calls, restarts it with backoff, and splices every call that names one of the spec’s tools; serve and the XFCE session use /run/cocoonbox/desk.sock for both, the scaled screenshot is computer_screenshot and the saved-flows listing gui_flows, and the toolhost runs without the system unit’s OOM protection; computer_state, computer_act, computer_end, screenshot and run screenshots run on shuttle’s desk_computer_* and desk_look verbs (wire, Computer calls), where a call without session uses its caller’s own default session; the toolhost keeps type_from_file (it reads the card and checks the page field, then clicks and types on shuttle) and Chrome’s launch_app (the agent’s window of the browser tools’ Chrome, then shuttle’s desk_still and desk_look), runs an action list that holds one of them step by step at the scale desk_computer_scale gives, grounds a target on shuttle’s desk_look, desk_shot, desk_click and desk_title_at, and reads the window titles of its held-file notes from desk_windows |
/etc/cocoonbox/agent.env keys:
GW_BASE_URL=http://10.203.0.1:18090/v1 <through the sandboxd egress proxy>
GW_API_KEY=unprovisioned <a placeholder; the egress proxy injects the box's own key, so the guest never holds one>
MODEL_MAIN=deepseek/deepseek-v4.1-flash
MODEL_COMPUTER=anthropic/claude-sonnet-5
AGENT_LISTEN=127.0.0.1:7443
CONNECT_URL=http://10.203.0.1:18091/mcp <the Connect box door, through the sandboxd egress proxy; absent: no connector tools and, on cbcore, no flow sync (the flow library is the same door's /flows)>
The agent’s toolhost (through shuttle’s /run/cocoonbox/desk.sock, which splices the toolhost’s calls to its service socket after the first frame) and the desk API (/opt/desk/lib/desk.mts, desk) speak shuttle’s desk wire, implemented once in shuttle-desk-core (desk_core::wire, a git dependency pinned to a shuttle tag), which the toolhost also takes the desk call arguments from:
{"agent_id": string, "tool": string, "args": object}; agent_id defaults to "" and args to null. Desk calls name desk_<verb> tools with the verbs and JSON of the desk tool (below).desk_wait, model_decide, model_ask and model_ground are shuttle’s (its wire, Desk calls and Model calls). shuttle makes the model calls with its box config to gw at GW_BASE_URL as plain-http requests through the egress proxy, which sets the key, so code that runs as user holds none. The toolhost relays desk and model calls to shuttle at SHUTTLE_SOCKET and answers none itself; a desk or model call that shuttle leaves unanswered for 30 s, or a missing desk service, gives the desk service is not answering.{"text": string, "is_error": bool, "image_b64"?: string, "secrets"?: [[field, value], …]}: image_b64 is a screenshot or crop when the tool took one; secrets (toolhost only) lists the card fields a call typed from a file, so the agent scrubs them from transcripts; serve registers and scrubs them on every frame it reads, through shuttle or not, which is why serve is the only MCP front in a box: the service spec lists every toolhost tool with "mcp": false, so shuttle’s MCP server offers none of them. A tool failure is is_error: true with the reason in text, never a closed connection.state_read: after the request, a JSON stamp frame {size, mtime_ms, backup, error?}, then raw data frames (a big-endian u32 length and the bytes), an empty data frame at the end, then a second stamp taken after the read.state_write: after the request the client sends raw data frames and an empty one, and reads one response frame once the file has its mode and mtime.exec: one response frame (an error ends the call), then tagged frames both ways: one tag byte, a big-endian u32 length, the bytes. From the toolhost: 1 stdout, 2 stderr, 3 exit code (a big-endian i32, the last frame). To the toolhost: 4 stdin, 5 end of stdin, 6 signal (a big-endian i32), 7 terminal size (big-endian u16 rows and cols).cocoonbox.agent.v1.* call but Session.Screen unchanged to the box’s port 7443 through the sandboxd port relay, adding x-cocoonbox-credential; it sends no x-cocoonbox-edge-token, and agent.env carries no EDGE_TOKEN. It drops the client’s authorization and any client-sent x-cocoonbox-credential or x-cocoonbox-edge-token.v1.<p>.<s>. p is base64url without padding of key_id|box_id|wake_id|exp, with exp in unix seconds. s is base64url without padding of the ed25519 signature over the ASCII bytes v1.<p>. A v1 credential is the box’s owner.v2.<p>.<s>, with a fifth field principal = share:<share id>:<read|write>[:<user id>] in p and the signature over v2.<p>. The agent takes the caller only from this field. A share principal reaches /mcp only; every agent gRPC method answers it PERMISSION_DENIED. The agent answers Session.Screen UNIMPLEMENTED, since the edge serves it.read share’s read_file and list_dir reach only paths that resolve beneath /workspace, .. and symlinks included (design §23.6). Any other path is a tool error: <path> is refused: a read-only share reads files only under /workspace. The agent sets workspace_only: true in the arguments of every call of a read share, and the guest cannot unset it. On a cbcore box a path that resolves into /workspace/flows is refused too: <path> is refused: a read-only share does not read saved flows (design §28.3).key_id, box_id and wake_id equal the file’s, whose signature verifies with the file’s key, and whose exp is at most 2 min in the past; otherwise UNAUTHENTICATED.UNAVAILABLE “identity not provisioned”. A file it refuses (owner, mode, content) gives UNAVAILABLE “identity invalid”. Every service answers this way, Session.Hello included.Events, Exec) are proxied frame by frame with no buffering; a client’s half-close reaches the agent as one.Session.Screen does not reach the agent: the edge serves it from the box’s 127.0.0.1:5900 through the sandboxd port relay, with the same caller, ownership and readiness checks as a proxied call. The first client frame must be open; data frames carry raw RFB both ways, a client’s half-close half-closes the RFB connection, and the stream ends OK when the RFB server closes. A share’s viewer reaches the same server through the console bridge, filtered to view-only. A box holds at most 8 such relays at once, owner and share streams together; one more answers RESOURCE_EXHAUSTED. There is no idle timeout, since a live view of a still screen is quiet.The agent writes the objects of a save through presigned URLs and reads them back in a restore. The api opens them for BrowseSnapshot, RestorePaths and the GC, and writes manifests itself (deltas, and the full manifest again before an agent reads one). docs/fixtures/state-v1.json holds one sealed manifest and one sealed chunk under a fixed key; the agent tests (state::manifest) and the api tests (boxes) both open it, so a change on either side that breaks the other fails a test.
key of PlanRequest and RestoreRequest (at least 32 bytes; key_id names it). The id key is HMAC-SHA256(state key, cocoonbox state chunk id v1), the seal key HMAC-SHA256(state key, cocoonbox state seal v1).version, parent, key_id, taken_at_ms, consistency (live or quiesced), files, agent_db (left out without one: {size, chunks}), skipped (paths left out because they kept changing). Each files record: root (home is /home/user, workspace is /workspace), path (relative to the root, /-separated, with no . or .. parts), kind (file, dir or symlink), mode, mtime_ms, size, target (symlinks only), chunks (the file’s chunk ids in order, left out when empty).version and files only. Its delta of a manifest (<id>.d, sealed like an object with <id>.d as associated data and checked against <id> once rebuilt) copies files records byte for byte. So a manifest stays compact JSON with one files array, and one record always serializes the same way. A change to anything in this section bumps the version and lands here first.The CLI and the app sign in through the same authorization server, as first-party clients with a token of the whole account. Each sign-in is a device.
First-party clients are fixed in the api and never registered: cocoonbox-cli (cocoonbox CLI) and cocoonbox-app (CocoonBox app), each with redirect URI http://127.0.0.1/callback on any port (RFC 8252 §7.3). Their only scope is account, which no other client may ask for: an agent asking for it, or a first-party client asking for anything else, gets invalid_scope back.
Flow (loopback, RFC 8252). The client listens on 127.0.0.1:0, makes a PKCE pair and a state, and opens <endpoint>/cb/oauth/authorize?response_type=code&client_id=cocoonbox-cli&redirect_uri=http://127.0.0.1:<port>/callback&scope=account&code_challenge=…&code_challenge_method=S256&state=…&device_name=<host name>. device_name (at most 64 bytes of UTF-8, else invalid_request) names the device on the consent page and in the device list. POST /cb/api/oauth/check answers account: true and device_name, and the page asks “Sign in the cocoonbox CLI on
Device tokens are shaped as the personal token (cb_ + 64 hex) and work wherever it does: gRPC, the box and control MCP, the screen, rsh, COCOONBOX_TOKEN. They stay in user_tokens (users migration 0009: id dev_ + 16 hex, user, sha256, client, label, created and last-used times) and in the same in-memory index as the personal token, so a call costs one map lookup; the last use is written at most once a minute per device. A device token has no expiry. It ends when the user revokes it, when the client sends it to POST /cocoonbox.oauth/revoke (token; 200 for an unknown token, and a personal token is left alone), or with its user’s disable (until re-enabled) or delete. Regenerating or revoking the personal token, or changing the password, leaves device tokens alone. A revoke notifies cocoonbox_users, so every api drops the token from its index. Every call still open on a revoked or replaced token (a gRPC stream, a box or control MCP exchange, a Connect door call) ends with it.
The clients. cocoonbox login uses cocoonbox-cli; the app’s sign-in sheet has “Sign in with browser” with cocoonbox-app (device name: the Mac’s computer name) above the paste field, and a Cancel that ends the wait and frees the port. Each revokes on sign-out only the device token it got through the browser: the CLI keeps the token’s source per endpoint in cli.json (browser, paste or app), the app in its defaults, so cocoonbox logout leaves a token copied from the app alone. A new CLI login revokes the browser token it replaces.
The user. The console’s Devices section lists them (GET /cb/api/devices: id, client, label, created_at, used_at) and DELETE /cb/api/devices/{id} revokes one at once (404 for a device of another user or one already gone). DELETE /cb/api/me/token revokes the personal token at once, 204 also when there is none; device tokens stay.
cocoonbox.control.v1.Account (proto/cocoonbox/control/v1/account.proto, the personal token or a device token): ListDevices answers the console’s list plus current, true for the device whose token made the call; RevokeDevice{device_id} revokes one (NOT_FOUND for another user’s device or one already gone); RevokeToken revokes the personal token; SignOutEverywhere signs the caller out everywhere (below). cocoonbox devices [--json] lists them, * marking the CLI’s own, and cocoonbox devices revoke ID revokes one.
Sign out everywhere. POST /cb/api/me/sign-out-everywhere (console session, 204, the session cookie cleared), Account.SignOutEverywhere and cocoonbox logout --everywhere (which then deletes the CLI’s stored token) end every sign-in of the user at once, the calling one too: every console session, the personal token, every device token and every agent’s OAuth grant with its tokens, and every call open on any of them. Boxes, shares, connected accounts, keys and the boxes’ gw and door keys stay. The grants go first, then the sessions and tokens in one transaction; a failure leaves the rest for a retry. A named share’s screen open in the console ends with its viewer too. Every api ends the calls of a revoked device or personal token once it reloads its index; the calls of a revoked grant and the user-wide end of sign out everywhere end only on the api that served the revoke, which is every call while one api runs, the deployed contract.
GitHub is the one identity provider. A dedicated GitHub OAuth App, apart from Connect’s GitHub App, signs people in: its id and secret come from LOGIN_GITHUB_CLIENT_ID and LOGIN_GITHUB_CLIENT_SECRET (/etc/cocoonbox/login.env), and its callback is <oauth.public_url>/cocoonbox.auth/callback, which Caddy routes to 127.0.0.1:18201. The package is api/signup, its tables in the users database under signup_schema_migrations.
Identities. identities(idp, subject, user_id, email, email_verified) with the subject GitHub’s numeric account id, unique per IdP account and per user and IdP. Only a signed-in user connects one (POST /cb/api/me/identities/github, then the GitHub consent); an email address never links an account. GET /cb/api/me/identities lists them, DELETE /cb/api/me/identities/github disconnects one, refused (409) for an account made through GitHub.
Sign-in. GET /cb/api/auth/providers answers {github, signup, invite_only}. POST /cb/api/auth/github/begin {return_to, invite} stores a flow (state, PKCE verifier, a nonce hash, 10 min, one use), sets the Lax nonce cookie cb_login on Path=/cocoonbox.auth/, and answers GitHub’s authorize URL (scope user:email, S256). The callback takes the flow only with its cookie’s nonce and the right state (a forged answer leaves it to the real one), redeems the code, reads /user and /user/emails (the primary address and whether it is verified) and drops GitHub’s token. A known identity of an enabled user gets a console session and goes to return_to, a /cb/ path (the consent page of a CLI or app sign-in sends its own). Begin and the callback allow 20 calls per client address in 15 min each. Failures and refusals go to /cb/?login_error=<code> (signup_closed, email_unverified, email_not_allowed, invite_required, declined, disabled, github_taken, github_linked, browser, too_many, github_denied, failed), which the page turns into text, so a link cannot put words on it. Linking an identity that waits in the queue drops its entry.
Signup (off by default). An admin sets signup_settings on the admin page (GET and PUT /cb/api/signup): enabled, invite_only (default on), seats, allow_domains and allow_emails (empty allows everyone), and a signup account’s daily_cap_micros (default $1), max_agent_boxes (default 1) and hourly_accounts (default 50). A new identity is refused when signup is off, the primary email is unverified, the email is outside the allowlist, signup is invite-only and the sign-in carries no usable invite, or an admin rejected the person; otherwise it joins signup_queue, at the front with an invite, and admission runs. A seat is one enabled non-admin account, admin-made or not; admins may exceed the cap.
Admission locks the settings row, counts the seats in use and the accounts admitted in the last hour, and admits the oldest waiting people into the free seats within the hourly brake, under that lock, so a freed seat admits one person across apis. It runs past the caller’s cancel, and each person in a savepoint, so one failure leaves the others admitted; the account’s id comes from the queue entry’s, so a retry reuses an account a failed attempt made. It runs on a signup, a user deleted or disabled, a settings change, a person leaving, and every 5 min. Admitting makes the account at once: users.idp github, the username from the GitHub login lowercased and cleaned to the user name grammar with -2, -3, … on a clash (no rename), its own gw daily cap, and max_agent_boxes; the identity is linked and the person’s next sign-in, or the one that admitted them, signs them in.
The queue. A queued person gets the Strict cookie cb_queue (90 days) and the page /cb/queue: GET /cb/api/queue/me answers {status, position, waiting, email, since}, DELETE /cb/api/queue/me leaves and deletes the entry. No email is sent. The consent page of a CLI or app sign-in carries an invite from its own URL into its GitHub sign-in (cocoonbox login --invite CODE puts it there), so a new person signs up and gets back to the consent in one pass; the Allow click stays. Admins list the queue (GET /cb/api/queue) and act on an entry (POST /cb/api/queue/{id}/admit|front|reject; admit makes the account past the cap). Invites: POST /cb/api/invites {uses 1..1000, days 1..90} answers the code once (cbi_ + 64 hex, its sha256 stored) for the link /cb/?invite=<code>; GET lists them, DELETE /cb/api/invites/{id} revokes one.
Invite-scoped tokens. An admin mints one on the admin page (POST /cb/api/invite-tokens {label} answers cbit_ + 64 hex once; GET lists them with the invites each made and its last use, DELETE /cb/api/invite-tokens/{id} revokes one). Only its sha256 is stored (invite_tokens, signup migration 0002), and it lives while its admin is an enabled admin. Sent as Authorization: Bearer <token>, it reaches only POST /cb/api/invites (one use, days 1 to 7, default 2; any other uses is a 400) and GET /cb/api/invites (the invites it made). Every other route of the api’s HTTP server (the console, the control and box MCPs, the Connect door and callback, the sign-in callback, the OAuth endpoints) answers 403 to a request carrying one, the check wrapping the whole mux; the bearer check of gRPC and MCP refuses the cbit_ prefix before any lookup, so no user token check accepts one. Logs name the token’s id, never the token. cocoonbox invite create [--days N] and invite list read it from COCOONBOX_INVITE_TOKEN, else from --token-file PATH or COCOONBOX_INVITE_TOKEN_FILE, never from argv. Seats cap the admissions its invites bring.
Agent boxes of a user. users.max_agent_boxes (users migration 0010; null for no cap, the admin-made default) bounds the boxes all of a user’s grants hold together, checked under the owner’s row lock when a box is added, and the tenant’s max_claims counts the agent boxes up to it: max_boxes + min(agent boxes, max_agent_boxes) + 1. An admin sets it per user (PATCH /cb/api/users/{id} {max_agent_boxes}, 0 for no cap).
Self-delete. POST /cb/api/me/delete {name} with the caller’s own user name runs the admin delete (disable, release the boxes, delete the state, revoke the connections, delete the row, whose cascade removes identities, device tokens, grants and the queue entry), ends the session and admits the next person; an admin’s account is refused (403).
https://<api host> (Caddy terminates TLS; /cocoonbox.* → edge h2c://127.0.0.1:18200).authorization: Bearer <user token> (the personal token or a device token); agent calls add x-cocoonbox-box: <box id>.NOT_FOUND (no such box for this user (it may have been deleted)) on every Boxes, Shares and agent call, whether another user owns it or it was deleted: the answer never says which. A client that held the id refreshes List; after a delete and a create under the same name, the new box has a new id.Session.Screen, bridges it to a loopback TCP port, and points the RFB client at that port.Session.Hello names the box’s automation (AUTOMATION_DESK or AUTOMATION_ANDROID, from the guide its image holds; a box without a guide, and an older agent, leave it unspecified; agents before M7 sent AUTOMATION_PLAIN on cbdesk) and the screen size the image boots at, 1280x800 on a desktop and 720x1280 on Android; a later change in the box, such as wm size, is not reflected.max_boxes boxes (the per-user setting), each with a name unique among them: 1 to 32 of a-z 0-9 - _, starting with a letter or digit, never starting with cb-. List returns them oldest first; a box without a machine has state BOX_STATE_UNSPECIFIED (absent). List and Watch also return offered_images: the default image, then each rebase_images entry once, the images and families (the name before :) that Create takes. Ensure{box_id} creates or wakes that box; an empty box_id is the user’s first box, and adds one named main when the user has none. Create{name, image, flavor} adds a box and starts it. Rename{box_id, name}. Delete{box_id} is an operation: a quiesced save when the box runs (PHASE_SAVING), then PHASE_DELETING while every machine of the box is removed; a failed save keeps the box. The box’s live share is revoked with it. Its saved state stays in the store until the GC (state.gc) removes it: the first pass that finds the box deleted records the time in <user>/boxes/<box>/deleted.json, and the first pass state.gc.deleted_retention (default 7 days) after that deletes every object of the box and every chunk no other box of the user references.max_running (users migration 0005, set by an admin, 1 to the user’s max boxes), else api config max_running_boxes (default 1). An Ensure that would create or wake a machine, and every Create, fail with FAILED_PRECONDITION naming the boxes that hold the limit (box <name> (<id>) is running, and <n> may run at a time; hibernate it first, or boxes <name> (<id>), … are running, and <n> may run at a time; hibernate one first) while that many other boxes of the user are starting, running or hibernating. The api never hibernates a box to make room.cocoonbox.control.v1.Shares (proto/cocoonbox/control/v1/shares.proto), for the caller’s own boxes. The link is <endpoint>/cb/s/#<secret>. The api keeps the secret sealed (design §23.5) and returns it to the box’s owner in every Share (secret, empty for a share created before migration 0006); a guest or a share secret never gets it.ListSnapshots{box_id} lists the box’s kept snapshots, newest first; changed_files is absent for saves by agents before cbdesk:v20. BrowseSnapshot{box_id, snapshot_id, dir} lists one directory of a kept snapshot by box path ("" lists /home/user and /workspace), at most 5000 entries and then truncated; the box may be hibernated. RestorePaths{box_id, snapshot_id, paths} is an operation on a running box (PHASE_RESTORING_FILES): the agent copies the paths into ~/Restored/<save time, 2006-01-02T1504Z>[-n]/<box path without its leading slash> and changes nothing else. A path outside /home/user and /workspace, with ., .. or empty parts, is INVALID_ARGUMENT; one the snapshot lacks fails the operation; an unknown or pruned snapshot is NOT_FOUND./cb/s/ opens wss://<endpoint>/cb/s/ws (same Origin only) and sends the secret as the first text message. The api answers {"ok":true}, or {"error": <message>, "code": <lower-case gRPC code>} and closes, then carries RFB as binary messages. It drops every client input message (view-only), limits the viewer to 32-bit pixels and the encodings it can frame (Raw, CopyRect, ZRLE, DesktopSize, LastRect, Cursor, ExtendedDesktopSize, Fence, ContinuousUpdates), and drops every ServerCutText, so the box’s clipboard never reaches a viewer. A named share takes the viewer from the console session cookie.agent until its first user message with text names it: the first non-blank line, trimmed and cut to 24 characters (Unicode scalar values, so CJK text never splits inside a character). No model call is made. The title is set once and is published as an Agent event.Agents.Rename{agent_id, name} sets the trimmed name and publishes an Agent event; an empty name is INVALID_ARGUMENT, an unknown agent NOT_FOUND. A renamed agent is never auto-titled, so the user’s name wins whichever comes first.Agents.Delete{agent_id} cancels the agent’s running turn and waits for it to end, drops its queued turns, then removes the agent, its turns, transcript entries and nonces from agent.db in one transaction, ends its toolhost shell session (the toolhost kills its bash and that bash’s process group, background jobs included), and publishes Event.agent_deleted. Pending take-over approvals end with the turn. A turn waiting for any approval holds off no quiesce (a quiesced save, Rebase, Delete or park); after the answer it waits for a quiesce in progress before its next step. An unknown agent is NOT_FOUND. An mcp:* agent whose MCP session is still open is refused with FAILED_PRECONDITION; the client closes its session first. After a delete, Transcript for that id returns an empty page and Send returns NOT_FOUND.mcp:* agents removed by the prune also have their shell session ended and publish agent_deleted.Rename and Delete like every other cocoonbox.agent.v1 call.Session.ActiveOrigin returns location.origin of the tab the user sees: the visible tab titled as the X active window when exactly one matches, else the window the browser tools last acted on, else the first page target CDP lists. It never starts Chrome: without Chrome or a tab it answers FAILED_PRECONDITION.Session.FillCredential{origin, username, password | code, submit} fills that tab only when its location.origin equals origin exactly (scheme, host and port); otherwise FAILED_PRECONDITION and nothing is typed. The origin check and the fill run in one script in the page. With a password it fills the focused field, else the first password field and the text field before it; with a code, the focused field, else the one-time-code field, else the first text field. submit submits the field’s form, or presses Enter when there is none. The response lists the kinds of field filled.agent.db, and the agent and toolhost zero their copies after the call.Session.Exec, cocoonbox rsh)Session.Exec (bidi stream). The first client frame is start{command, tty}; then any of stdin chunks, eof, signal (INT or TERM, sent to the command’s process group) and resize (rows and cols of a tty). eof, or the client half-closing the stream, closes the command’s stdin; signals still go through after eof. The server sends stdout and stderr chunks as they come and ends with one exit_code: the exit status, or 128 plus the signal that ended the command. A first frame other than start is INVALID_ARGUMENT.bash -lc <command> (a login shell, so ~/.profile puts ~/.local/bin and the user’s tool shims on PATH) as user in /workspace, with the shell tool’s environment: the toolhost’s (shuttle’s environment for its services), plus TERM=dumb, PAGER=cat, GIT_PAGER=cat, NO_COLOR=1. Without tty the command runs in its own process group on pipes. With tty{term, window, terminfo} it runs as a session leader on a new pseudo-terminal of that size, with TERM set to term (xterm-256color when empty) and without the pager and colour variables. When the box has no terminfo entry for term (infocmp as user, ~/.terminfo first) and terminfo carries its source, the toolhost compiles it with tic -x -o ~/.terminfo as user (5 s timeout) and keeps term. With no source, a source that fails or does not define term, the command gets TERM=xterm-256color and stderr gets one line saying so. A terminfo over 256 KiB is INVALID_ARGUMENT. stdout then carries stderr too, and eof changes nothing. Every hop moves one chunk of at most 64 KiB at a time under the HTTP/2 windows, so a slow reader slows the command and nothing buffers without bound.exit_code, once the command exited and closed stdout and stderr (or the terminal). A cancelled stream, a lost connection or an agent stop kills the command’s process group, a command blocked on a full stdin included. A signal queued behind a blocked stdin write waits for it.PERMISSION_DENIED, and a share secret is no gRPC token at the edge (UNAUTHENTICATED).cocoonbox rsh [--box NAME|ID] [--endpoint URL] [-t] [-l USER] [HOST] COMMAND... (macos CLI). It calls Exec with the token the bridge uses (COCOONBOX_TOKEN, else the Keychain) and the same endpoint resolution.
-- ends the options. The words after the host are joined with spaces into command, as rsh and ssh do.--box wins. With it, a first word that is box or names one of the user’s boxes (name or id, a user@ prefix ignored) is dropped as rsync’s or git’s host word. Without it, HOST names the box. -l USER is ignored.-t asks for a terminal when stdin is one: the CLI’s terminal goes raw, and TERM, its local infocmp -x source (when infocmp has it, at most 256 KiB) and the window size go along, followed by each SIGWINCH. Otherwise it says so on stderr and runs without one.SIGINT or SIGTERM the CLI gets is sent to the command; a second ends the CLI with 255, which cancels the stream and kills the command.Ensure, progress on stderr).exit_code.rsync -az -e "cocoonbox rsh --box main" ./ box:/workspace/repo/, GIT_SSH_COMMAND="cocoonbox rsh" GIT_SSH_VARIANT=simple git clone main:/workspace/repo. Without GIT_SSH_VARIANT, git first probes for OpenSSH with -G, gets 255, and uses its simple variant anyway.A local coding agent’s task moves into the box and back. The local agent writes a hand-over; a fresh box CLI session, signed in inside the box by the user, continues from it. The Mac packer is cocoonbox offload / cocoonbox back (macos); the box packer is cocoonbox-agent offload <verb> (agent), run as user through cocoonbox rsh.
Requires cbdesk:v22 or later (git, rsync, tmux and mise since v20; build-essential and the Exec terminfo upload since v21; the box packer, the inbox unit and the secrets tmpfs since v22), and the box CLI of the chosen kind installed and signed in inside the box. Git repositories only.
Names.
<absolute Mac path of the top level>\n<oldest root commit of HEAD> (empty for an unborn HEAD). It names the repo everywhere below./workspace/<name>. name is the top level’s basename with every character outside A-Za-z0-9._- turned into - and leading dots dropped, at most 64 characters, then -2, -3, … while another key holds it. begin assigns it once.claude, codex or cursor, the box CLI. --kind picks it; the default is the local session’s kind.git add -A over HEAD’s tree, commit-tree with HEAD as parent), is refs/cocoonbox/offload/<id>. S’, the box’s snapshot at back, made the same way in the box, is refs/cocoonbox/back/<id>. The box’s branches arrive on the Mac as refs/cocoonbox/box/<branch>.cb-off-<repo key>-<kind>.--no-repo): no rsync, bundle, S or merge; key agent- and the first 8 hex of sha256 of an agent id; the box CLI runs in /home/user/cocoonbox-agents/<kind>. No Mac entry makes one since 2026-10-03: the app’s own local agents drive boxes over MCP, and offload starts from the user’s own terminal session (design §27.4).Directories.
| Path | Owner, mode | Holds |
|---|---|---|
Mac: ~/Library/Application Support/CocoonBox/offload/<repo key>/ |
the user, 0700 | handover.md (path from cocoonbox offload handover-path), env.json, the bundle while it is sent, the box’s back.md, and offload.json while the offload is in flight: id, repo, box, box_name, name, dir, kind, base (S), top, started_at_ms, seen (the last outbox seq offload inbox showed); written before send, removed after end |
Mac: ~/Library/Application Support/CocoonBox/offload/repos.json |
the user, 0600 in the 0700 offload/ |
per repo key: the opted-in secret entries (file:<repo path>, aws, kube, gcloud, ssh:<key name>; names and paths only, set with cocoonbox offload secrets add\|remove) and whether the user said yes to the size confirm |
Box: /home/user/.local/share/cocoonbox/offload/ |
user, 0700 |
index.json (repo key → name), excludes, and per repo key the inbox, outbox, session record, hooks file, first prompt, env.log, handover.md (the box agent’s) and stage/ (emptied after each send). The state layer saves it |
Box: /run/cocoonbox-offload/secrets/<repo key>/ |
user, 0700; files 0600 |
opted-in secrets, on a 64 MiB tmpfs the image mounts at /run/cocoonbox-offload (user, 0700). Never saved |
Offload, Mac side (three Execs):
cocoonbox rsh --box B -- cocoonbox-agent offload begin ….rsync -az --delete --exclude-from=<ignored> --exclude=/.git/objects/ -e "cocoonbox rsh --box B" <top>/ box:/workspace/<name>/, <ignored> from git ls-files -oi --exclude-standard --directory on the Mac plus begin’s ignored, each anchored at the top (/<path>). No --delete-excluded, so the box’s own ignored files (target/, node_modules/) stay, also where the Mac has none.cocoonbox rsh --box B -- cocoonbox-agent offload send … < stream.History travels as a bundle in the stream, never by rsync: git bundle create <stage>/offload.bundle --all --not <every value in begin's refs the Mac has>, left out when git refuses it as empty. Only objects reachable from refs travel (older stash entries do not). A linked worktree (.git is a file) is sent without .git; the stream then carries its HEAD and index, and the box copy is a plain repo built from the bundle. The box takes the bundle with git index-pack --stdin --fix-thin on its pack and then sets its refs, never with git bundle unbundle: the refs rsync brought already name objects that only the bundle holds, and unbundle refuses its prerequisites as “not connected”.
Back, Mac side:
offload send --repo K --back [--interrupt], unless the outbox already holds this offload’s back.offload read --repo K --since N --wait S, repeated until a back entry, for at most 600 s in all; then back fails (exit 1) with the session’s status, and BACK stays queued for the CLI’s next idle.GIT_SSH_COMMAND="cocoonbox rsh --box B" GIT_SSH_VARIANT=simple git fetch box:/workspace/<name> '+refs/heads/*:refs/cocoonbox/box/*' '+refs/cocoonbox/back/<id>:refs/cocoonbox/back/<id>'.git merge-tree --write-tree applied uncommitted, else the branch cocoonbox/back-<UTC yyyymmddThhmmssZ> at S’ and an untouched tree.
refs/cocoonbox/pre-back/<id> → the old HEAD. The new index (the staged changes carried over, else the box HEAD’s tree) is built aside and held as git’s index.lock before the work tree changes; a held lock, or a patch that does not apply, changes nothing.offload end --repo K.Verbs. stdout is one JSON line: {"ok":true,…}, or {"ok":false,"code":"usage|refused|failed|inbox","error":"<one line>"}. stderr carries progress for a human. Neither ever carries a secret; only read carries message bodies. Exit codes: 0 done; 1 failed, rolled back as stated; 2 usage; 3 refused, nothing changed; 4 inbox, the inbox is not running. A begin refusal also carries findings (the box-side blocks, as in begin’s output) and cli next to error.
| Verb | Run by | Does; stdout fields |
|---|---|---|
begin --repo K --name N --kind C --id ID [--no-repo] |
Mac | the box half of the preflight (below); refuses on a block; else takes the copy for ID, records the name and makes /workspace/<name>. dir, copy (absent or mirror), refs ({"<ref>":"<oid>"} of the copy), cli (kind, version, signed_in, account; for the claude kind hooks_blocked, the first of /etc/claude-code/managed-settings.json, managed-settings.d/*, ~/.claude/remote-settings.json and ~/.claude/settings.json that sets allowManagedHooksOnly or disableAllHooks, which cocoonbox offload prints as a warning), ignored (the copy’s git-ignored directories relative to it, as git ls-files -oi --exclude-standard --directory lists those ending in /), findings ([{"check","outcome":"block\|warn","detail"}]) |
send --repo K --kind C --id ID [--prepare] [--no-repo] < stream |
Mac | the steps below. delivery (started), accepted, tmux, timings_ms per step |
send --repo K (--message \| --back) [--interrupt] < text |
Mac | a message, or BACK, to the repo’s running session. seq, delivery (pasted or queued); no running session is refused |
read --repo K [--since N] [--wait S] |
Mac | outbox entries after seq N; --wait long-polls up to S ≤ 600 seconds. entries ([{"seq","kind","offload_id","cli","time_ms","body"}]), status |
status [--repo K] |
Mac, app | sessions ([{"repo","name","cli","offload_id","status","copy","tmux_alive","hook_at_ms","hooks_silent","transcript_mtime_ms","window_activity_ms","inbox_seq","outbox_seq"}]) |
end --repo K |
Mac, after the fetch | ends the CLI (/exit, /quit for codex, SIGTERM after 10 s), wipes the repo’s secrets, and marks the copy returned |
wipe-secrets --repo K |
Mac | removes the repo’s secrets now |
restart --repo K |
the user | the inbox’s restart |
post (--progress \| --question \| --back) [--repo K] < body |
box agent | an outbox entry; --repo defaults to COCOONBOX_OFFLOAD_REPO. --progress and --back refuse a body without every hand-over heading and list the missing ones. --back also makes S’. seq, status (posted, or ready for --back) |
env [--repo K] [--no-wait] [--json] |
box agent | waits for the environment step through the inbox’s env, at most 900 s, and prints per tool the wanted version and its source, what is installed, and ready, missing or failed, then each lockfile fetch. Text unless --json. An inbox that is down, or stops during the wait, is inbox; a step that an inbox restart cut short is failed. --no-wait reads the report as it stands without the inbox |
event --repo K --cli C --hook NAME < payload |
the CLI’s hooks | the inbox’s event, with the hook’s JSON |
serve |
cocoonbox-offload-inbox.service (user unit of user) |
the inbox |
Copy states: absent (no index entry or no directory); mirror (no CLI session started on it since the last end); unreturned (one did); busy (a session that is not stopped). begin refuses unreturned and busy. begin takes a mirror or absent copy for its offload id, and send refuses another id.
Send stream: a tar (ustar or pax) of regular files, read in order:
| Member | When | Goes to |
|---|---|---|
handover.md |
always, except --prepare |
the first prompt |
env.json |
always, except --no-repo |
the environment step |
offload.bundle |
when history is new | git index-pack --stdin --fix-thin on its pack (above), then deleted |
HEAD, index |
a linked worktree | the copy’s .git/ |
secrets/<entry> |
opted in | the tmpfs, straight from the stream |
send steps (box): read the stream; create .git/objects when absent and index the bundle (above); git -c core.fsmonitor=false update-index -q --refresh; check the copy equals S (a temporary-index snapshot’s tree equals S^{tree}, HEAD equals S’s parent), else failed; place secrets; write excludes; preset the CLI’s trust for the directory (claude: ~/.claude.json hasCompletedOnboarding and projects[<dir>].hasTrustDialogAccepted; codex: [projects."<dir>"] trust_level = "trusted" in ~/.codex/config.toml) and nothing else of its config; hand the first prompt to the inbox (start); wait until the CLI takes it (its prompt hook, else a new transcript file), at most 30 s, after which accepted is false. --prepare stops before the trust preset and runs only the environment step. Before the inbox accepts, a failure wipes the repo’s secrets, empties stage/ and releases the copy, which may stay partly synced: it held nothing unreturned, and the next offload syncs it again.
First prompt, composed by the box packer:
/workspace/<name>, the user is away and nobody answers prompts; run cocoonbox-agent offload env first; HTTP_PROXY and HTTPS_PROXY are set and can break tests that read them; before blaming its change for a failure, compare against S (git stash, or a worktree at S); commit as it goes and never push; post progress, questions and back with post, writing its hand-over to handover.md in the repo’s offload directory; the full hand-over template.git status --short, git log --oneline @{upstream}..HEAD (else the last 5 commits), Mac-only settings found in .git/config (credential.helper osxkeychain, an absolute core.hooksPath), and env.json in short.Box CLI in tmux cb-off-<repo key>-<kind>, a login shell in the copy, started with send-keys, the prompt read from a file. Bypass mode by default, as the offload sheet states: claude --dangerously-skip-permissions --settings <hooks file> "$(cat <prompt file>)", the hooks file (ours, never the user’s settings) also carrying "skipDangerousModePermissionPrompt": true, since the bypass-mode dialog would hold the first prompt; codex --dangerously-bypass-approvals-and-sandbox -c 'notify=["cocoonbox-agent","offload","event","--repo","<K>","--cli","codex","--hook","notify"]' "$(cat <prompt file>)"; cursor at O6. The tmux environment carries COCOONBOX_OFFLOAD_REPO, the MISE_<TOOL>_VERSION pins and the secrets’ variables. The claude hooks, all calling offload event: UserPromptSubmit → working, Stop and StopFailure → idle (or blocked), Notification → waiting_for_user (a blocked session stays blocked), SessionEnd → stopped (except reason clear). claude ends a turn that an API error stopped with StopFailure, not Stop. The hook commands send their stdout to /dev/null, so nothing reaches the model. codex: a delivered prompt → working, notify → idle (or blocked).
Inbox. Socket /run/cocoonbox/offload.sock, user:cbagent 0660, in /run/cocoonbox (2770, so the socket’s group is cbagent). The verbs (as user) and the agent (cbagent) connect; there is no port and no edge route, so from the Mac it is reached only through Session.Exec, owner only. One JSON object per line; each request gets one response line, except watch. Errors are {"ok":false,"code":"invalid|refused|failed","error"}.
| Request | Response | Does |
|---|---|---|
{"op":"send","repo","name","cli","offload_id","body","start"?,"back"?,"interrupt"?} |
{"ok","seq","delivery":"started\|pasted\|queued"} |
stores the message. start: a fresh CLI session with body as its first prompt, refused while one runs or when 3 sessions of the box are not stopped. Otherwise an idle session gets it pasted (tmux load-buffer, paste-buffer -p, Enter); a busy one queues it until its next idle. interrupt sends Esc first; back puts the BACK instruction first and drops a BACK still queued |
{"op":"post","repo","kind":"progress\|question\|back","body"} |
{"ok","seq"} |
an outbox entry |
{"op":"read","repo","since","wait_ms"?} |
{"ok","entries","status"} |
outbox entries after since; wait_ms long-polls |
{"op":"env","repo","wait_ms"?} |
{"ok","report"} |
the repo’s environment report (null without a step) once the step that this inbox runs has ended, or after wait_ms (at most 900 s) with done false; an unfinished report whose step this inbox does not run is failed at once |
{"op":"status","repo"?} |
{"ok","sessions"} |
as the status verb |
{"op":"restart","repo"} |
{"ok","delivery":"started"} |
below |
{"op":"event","repo","cli","hook","payload"} |
{"ok"} |
a hook’s report; status, queued delivery, blocked |
{"op":"rescan"} |
{"ok"} |
reloads the session records and runs the restart check below; the agent sends it, without reading the answer, after a State.Restore replaced the home |
{"op":"watch"} |
a line per change: {"repo","name","cli","offload_id","entry","seq","status"} |
the agent’s feed for Session.Events |
stopped, and one per repo.working, idle, waiting_for_user, blocked, stopped, from the hooks. status adds the transcript’s mtime and tmux window_activity as a second signal. A managed allowManagedHooksOnly or disableAllHooks drops the --settings hooks: when no claude hook arrived since the launch, the inbox reads the newest transcript every 5 s and, when its last user, assistant or turn_duration entry is a turn_duration written after the last prompt, applies it as Stop. hooks_silent is true once such a session has had no hook for 30 s after its launch.progress, question and back, posted by the box agent; blocked, written by the inbox when a Stop or StopFailure (codex: notify) follows the last delivered prompt with no progress or back since, and the transcript’s last assistant message starts with API Error or is the CLI’s usage-limit text (a fixture per CLI version). Its body is that message, at most 2 KiB. The status stays blocked until the next prompt.restart: a session that is not stopped, whose tmux session is gone, starts fresh from this offload’s newest progress hand-over, else its first prompt, after the line “You were restarted; continue from this hand-over and the repo.” The environment step runs again first. The restart op also takes a stopped session whose copy is unreturned, and refuses while its tmux session runs. A restore swaps the home under a running inbox, so the agent sends rescan once the swap is done, and the inbox runs this check again over the restored records.Event. Session.Events carries offload (OffloadUpdate, session.proto) for each outbox entry and each status change, from the agent’s watch: repo key, name, CLI, offload id, the entry kind and seq (unset for a status change) and the status. Never a body. Replayed like other events; owner only, like every Events.
Environment (env.json, written by the Mac packer):
{
"v": 1,
"tools": {"go": "1.26.5", "rust": "1.88.0", "node": "22.11.0", "python": "3.12.7"},
"package_managers": {"pnpm": "9.12.0"},
"files": ["go.mod", "rust-toolchain.toml", ".tool-versions"],
"lockfiles": ["go.sum", "Cargo.lock"]
}
tools: mise tool names, with the versions the Mac ran (go env GOVERSION, rustc -V, node -v, python3 -V); a tool the Mac lacks is absent. package_managers: from package.json packageManager, else the Mac’s version of the one a lockfile names. files: which of .mise.toml, mise.toml, .tool-versions, .nvmrc, .node-version, .python-version, .go-version, rust-toolchain, rust-toolchain.toml, go.mod, package.json, Cargo.toml, .devcontainer/devcontainer.json, flake.nix and shell.nix exist at the top level. lockfiles: which of go.sum, Cargo.lock, package-lock.json, pnpm-lock.yaml, yarn.lock and uv.lock exist.env.log): mise install <tool>@<exact version> for each tool, pinned for the session by MISE_<TOOL>_VERSION in the tmux environment, so no file in the copy or in mise’s global config changes. When mise has no such version, the version a declarative file names, else the newest patch of the same minor; env says which. Tools only a declarative file names: mise install in the copy. Then per lockfile: go mod download, cargo fetch --locked, npm ci, pnpm install --frozen-lockfile, yarn install --immutable, uv sync --frozen. devcontainer.json, flake.nix and shell.nix are hints in env only. The box agent installs what is still missing.Secrets (opt-in per repo on the Mac; never in argv, an environment variable or a log on the Mac, never by rsync, never in the state store):
secrets/file/<repo path>: /workspace/<name>/<repo path> becomes a symlink to the tmpfs copy, so a save holds the link only.secrets/aws, secrets/kube: AWS_SHARED_CREDENTIALS_FILE, KUBECONFIG. secrets/gcloud/<path>: a directory, CLOUDSDK_CONFIG.secrets/ssh/<key name>: added to an ssh-agent whose socket is on the tmpfs, SSH_AUTH_SOCK, then deleted.end, wipe-secrets or a box restart. A node-local hibernate image carries them (accepted, design §27).State excludes. At every send the box packer writes to excludes the copy’s git-ignored directories (git ls-files -oi --exclude-standard --directory, the entries ending in /), one absolute path per line. The state layer leaves out every listed path under /workspace/ and ignores any other line; a restore rebuilds them.
Hand-over. The first line starts # Hand-over: . Every one of these headings appears once, as its own line: ## Goal and definition of done, ## User preferences and constraints, ## Current state, ## Decisions made, ## Files touched, ## Next steps, ## Commands, ## Environment facts, ## Gotchas learned, ## Open questions. An empty section says none. At most 256 KiB. cocoonbox offload refuses a hand-over that breaks this before any check; post --progress and post --back refuse the same way.
Preflight. block stops with nothing changed; warn goes on only with the user’s yes (or a go-ahead given beforehand); confirm is a warn naming the size.
| Finding | Where | Detection | Outcome |
|---|---|---|---|
| Box image older than cbdesk:v22 | Mac | the box’s image in Boxes.List |
block: “upgrade the box first” |
| Not a git repository | Mac | git rev-parse --show-toplevel |
block |
| Box tools missing | begin |
git, rsync, tmux, mise on a login shell’s PATH |
block |
| Box CLI missing or signed out | begin |
claude --version, claude auth status; codex --version, codex login status; cursor-agent --version, cursor-agent status |
block, with “Install in box” (the vendor’s official installer over rsh, into /home/user) when missing and “Sign in in the box” when signed out; both accounts shown when they differ |
| Unreturned or busy copy | begin |
copy states | block: “bring the last offload back first” |
| Three box sessions running | begin |
inbox status | block |
| macOS-only work | Mac | *.xcodeproj, *.xcworkspace, a Package.swift with only Apple platforms, Podfile, fastlane/; a hand-over Commands line running xcodebuild, xcrun, security, launchctl or open |
block when a Commands line needs it; warn when the repo only contains an Apple target |
| Local services | Mac | listening TCP ports (lsof -iTCP -sTCP:LISTEN) named in the repo’s config, .env* or compose files; a running docker compose project of the repo |
warn, per service and port |
| VPN-only hosts | Mac | hosts in the remotes, config and .env* that resolve to private ranges, or only through a split-DNS resolver (scutil --dns) |
warn |
| Secrets not opted in | Mac | git-ignored .env*; the hand-over’s “Secrets needed”; unset names the code reads (os.Getenv, process.env, std::env::var) |
warn, with a per-repo opt-in |
| Size | Mac | rsync’s dry run over 500 MB | confirm; a yes is kept per repo in repos.json |
| LFS, submodules | Mac | filter=lfs in .gitattributes; .gitmodules |
warn: not carried |
Entry points (design §27.4). The user’s own claude runs /cocoonbox:offload and /cocoonbox:back from the plugin, which cocoonbox setup claude (or the app’s plugin install item) installs or updates; codex and other CLIs get the request to paste from cocoonbox offload --handover-prompt, or the user runs cocoonbox offload and cocoonbox back directly. The app lists the offloads in flight in its sidebar’s Offloaded section, from the offload.json records, offload status of each running box and offload events, and only watches: offload and back start from the user’s own TUI, never from the app. A back that finds no record but a back.md names that file.
Watching. The app’s Offloaded section shows the box CLI in a Ghostty pane through Session.Exec with a tty running tmux attach -t cb-off-<repo key>-<kind>. Before Hibernate or Rebase of a running box, the app and the CLI call offload status; a working session gets a warning to confirm (hibernate pauses it; an upgrade restarts it from its last hand-over).
net: none, size xlarge) from the template the api names (image), with metadata cb_machine = machine id and cb_owner = user. The api ignores claims without cb_machine.cb-<first 12 hex of sha256(user)> (every user created before several boxes per user, and every new user at creation); any other box is cb- and 12 random hex. A Rebase or restored machine is <box id>-r<unix time in base 36>, so the api finds a machine’s box by cutting its id at -r.Hibernate = sandboxd hibernate; Ensure on a hibernated box = wake.Boxes.SetWakeSchedule{box_id, cron, tz, awake_for_s} replaces the box’s schedule and returns it; ListWakeSchedules{} returns the caller’s; ClearWakeSchedule{box_id} removes it (NOT_FOUND “the box has no wake schedule” without one) and leaves a box the alarm woke running. A schedule carries next_at_ms, the next time of cron, and last_wake_at_ms, its latest wake from hibernation (0 before the first).cron is five fields (minute, hour, day of month, month, day of week, with lists, ranges, steps and names) or a descriptor such as @daily, read on the wall clock of tz, an IANA zone. A local time a DST change skips fires once, at the end of the gap; a repeated one fires once, at its first occurrence. awake_for_s is 60 to wake.max_awake_for (1 h); 0 is 15 min. INVALID_ARGUMENT for a missing or unknown zone, Local, a zone inside cron, a cron that never fires, or one whose times come less than wake.floor (1 h) apart. A user has at most wake.max_schedules (3); past that, FAILED_PRECONDITION quota reached: 3 wake schedules is the most this user may have. A box delete removes its schedule.Ensure as its owner, unless the schedule woke it less than wake.floor ago. A box that is hibernating gets it once the hibernate ends; a box that is starting or running gets nothing, and the time passes. A time that passed while the api was down gets one wake at once, never a backlog, with a full awake_for from that wake. The schedules of a disabled user wake nothing.awake_for, the api hibernates the box it woke, unless a client reached the box since that wake: any call the edge routes to it (agent gRPC, /cocoonbox.mcp/<box>, the screen, a share’s viewer) or the owner’s Ensure. Such a box is the user’s, and it stays up until someone hibernates it. While the box starts or an operation runs (a save, a Rebase), the api waits. The api’s own calls (provisioning, saves, ending a share’s sessions) are no reach. The schedule and its alarm state live in the users database (migration 0007), and the pass after a reach stores the hand-over there, so a restart keeps the next wake, a pending hibernate and a hand-over older than 15 s.user runs it; user has linger on, so the user manager runs while the box is up. Persistent=true also runs a time the box slept through. Keep the service’s TimeoutStartSec= 2 min under awake_for (below): a routine still running at the hibernate freezes with the box and goes on at the next wake, its network connections gone. The guest clock is right from the first second after a wake, so the timer fires at its own time: n = 10 wakes after 120 s hibernated were −13 to +1 ms off the host clock (design §15.13).Example. The E2E of 2026-10-10 ran these units with its own OnCalendar times, TimeoutStartSec=90s and a script that appends the time to a file. It did not run the claude line: cbdesk has no agent CLI until the user installs it, and offload’s “Install in box” puts it in ~/.local/bin, which is not on the user manager’s PATH. The timer fires up to a minute late, since AccuracySec defaults to 1 min (9.6 s in every E2E run), so keep TimeoutStartSec 2 min under awake_for.
~/.config/systemd/user/morning.timer
[Unit]
Description=Morning routine
[Timer]
OnCalendar=*-*-* 09:00:00 Asia/Singapore
Persistent=true
[Install]
WantedBy=timers.target
~/.config/systemd/user/morning.service
[Unit]
Description=Morning routine
[Service]
Type=oneshot
ExecStart=%h/routines/morning.sh
TimeoutStartSec=13min
~/routines/morning.sh (mode 0755)
#!/bin/sh
export PATH="$HOME/.local/bin:$PATH"
cd /workspace && your-agent-cli --print "Summarize the notes in /workspace/inbox in five lines." > /workspace/digest.md 2>> "$HOME/routines/morning.log"
In the box: systemctl --user daemon-reload && systemctl --user enable --now morning.timer. On the Mac: cocoonbox box schedule set main --cron "0 9 * * *" --tz Asia/Singapore --awake-for 15m.
cocoonbox box schedule set BOX --cron … --tz … [--awake-for 15m] [--json], list [--json], clear BOX; Kit BoxConnection.setWakeSchedule(boxID:cron:tz:awakeForSeconds:), listWakeSchedules() and clearWakeSchedule(boxID:).Mode A (the in-box loop) stays. Mode B lets an agent the user already runs — a local coding agent on their Mac — drive the box through the same tool layer. Both modes share the toolhost; neither owns the desktop exclusively.
https://<api host>/cocoonbox.mcp/<box id> — MCP Streamable HTTP (protocol 2025-06-18 or later; POST for JSON-RPC, GET for the server stream, Mcp-Session-Id header). Caddy routes /cocoonbox.mcp/* to the edge’s MCP listener 127.0.0.1:18201 (HTTP/1.1 and h2c, flush_interval -1) ahead of the /cocoonbox.* rule, so agent gRPC keeps the native transport on 18200.Authorization: Bearer <user token>, or Authorization: Bearer <share secret> (cbs_ and 32 hex; tried only when no user token matches the bearer) for a guest of the box’s live share, plus X-Cocoonbox-Guest-Token: <the guest's user token> for a share that names its users, or an OAuth access token on a box its grant created (OAuth for agents below; the edge adds the owner’s credential, and a box the grant did not create is 404 as for another user’s box; with OAuth on, a 401 that is not a share refusal carries the OAuth challenge). The edge checks ownership of <box id>, or that the share is live, unexpired, for this box and names the guest; 401 for a missing or bad guest token, 403 for every other share refusal, 404 for a box id the user does not own. It strips both headers, adds the credential (v2 with the share principal for a guest) as for gRPC, and proxies the HTTP exchange unchanged (streaming, no buffering) to http://<box>:7443/mcp through the sandboxd port relay. The agent refuses with 401 where gRPC gives UNAUTHENTICATED and 503 where it gives UNAVAILABLE.cocoonbox-agent serve answers /mcp on its 7443 listener next to gRPC (dispatch on content-type: application/grpc). Tool calls go to the toolhost exactly like the loop’s own calls.screenshot, computer_state, read_file, list_dir, browser_snapshot and browser_read, and any other tools/call is JSON-RPC -32602. The mode is the one of each request’s credential. A session belongs to the principal that opened it; another principal gets 404 for its id. A share principal (share id and user) holds at most 4 open sessions: a fifth initialize gets HTTP 429 with JSON-RPC -32000 until one closes. Share sessions declare tools.listChanged, and their GET stream sends notifications/tools/list_changed when a request’s mode differs from the one the session last listed tools in. Every tool call counts as in flight for a quiesce; past the 30 s quiesce wait, a share session’s running call is interrupted (interrupted: …, its shell session ended), while the owner’s are not.shell, read_file, write_file, edit_file and list_dir on every box; a cbcore box adds browser_navigate, browser_snapshot, browser_click, browser_type, browser_read, computer_state, computer_act, screenshot, script_run, gui_plan, desk, browser_fetch and browser_tabs. On a cbcore box browser_navigate waits, once the page loaded, until the body’s text length held over 150 ms with no XHR or fetch request ended meanwhile or in flight for less than 1.5 s, at most 3 s more. On a cbcore box browser_navigate, browser_snapshot, browser_click, browser_type and browser_read take an optional tab: without it they act in the caller’s own window as on cbdesk; with "active" or a target id that browser_tabs lists they act on that tab of the user, where browser_tabs lists the page tabs whose first history entry is not an agent window’s, with the tabs whose document.visibilityState is visible marked as in front, and "active" is the one tab in front, else the one of those titled as the X active window; a read share gets an error for tab. On a cbcore box browser_navigate also takes front: true calls CDP Page.bringToFront on the tab once the page settled, so its window becomes the active window with the keyboard focus; without it the tab stays in the background. browser_fetch {url} reads a page in a hidden tab of the box’s Chrome (Target.createTarget with hidden: true: no window, no tab strip entry, the user’s cookies; a document response that is an attachment or not HTML, text, XML or JSON fails through Fetch, so nothing downloads), renders at 1280×800 (a hidden tab has a 0×0 viewport otherwise), waits for its DOMContentLoaded and networkAlmostIdle (at most 15 s, and 2 s past the DOMContentLoaded), returns Page: <title> (<url>) and the body’s innerText capped at 20000 characters, and closes the tab; a read share does not get it, since it reads any URL with the owner’s sign-ins. Screenshots come back as MCP image content (PNG, 1280×800 unless max_edge is given; on a cbcore box shuttle captures full-size screenshots over MIT-SHM, falling back to cua-driver for max_edge or when a driver cursor overlay is shown, and sends lossless WebP, or JPEG q80 when a sample of the pixels has photo-like colour counts; cbcore’s cua-driver runs with --no-overlay, so it draws no agent cursor).cbcore:vN is the desktop, with the desk runtime and shuttle (image/desk, cut.sh); the frozen cbdesk:vN boxes keep the agent their image carries. The agent offers the desktop tools, script_run, gui_plan, desk and the resource below only when /opt/desk/AGENTS.md exists at its start; a box without a guide offers shell and files only, with instructions that say so. On a cbcore box the initialize instructions are a shorter head (the computer, paths, shell), a blank line and image/desk/routing.md, kept within 1600 characters so the Mac bridge’s prefix leaves the total under the agent CLI’s 2048-character cap; script_run, gui_plan, shell, browser_navigate, browser_read and browser_fetch carry _meta: {"anthropic/alwaysLoad": true} in tools/list, so the agent CLI loads them at session start instead of deferring them, the tool-tier policy (command, one GUI action, a plan or a script); the same file is a section of /opt/desk/AGENTS.md (provision replaces its <!-- routing --> line) and the tail of the box agent’s system prompt. On a cbcore box the computer_act description starts by telling the caller to send desktop-app GUI work to one gui_plan call and loops to one script_run script, and the 2nd consecutive computer_act call of an MCP session (no other tool call in between; a launch_app neither counts nor breaks the run) gets one line in its result text that says the same. On a cbcore box computer_act also takes actions, a list of single actions run in order with one screenshot at the end, stopping at the first failure, and each input action waits until the screen changed and held still for 40 ms, or 150 ms once a top-level window or menu mapped, unmapped or moved or the focus moved (160-pixel thumbnails without the panel and dock strips, over MIT-SHM every 10 ms, compared with a tolerance), at most 600 ms; a left click, Return, Enter, F5 or Alt+Left/Right, or typed text that ends in a newline, with a Chrome window (WM_CLASS Google-chrome) active holds 400 ms, at most 3 s once the screen changed (600 ms while it has not), so a page that loads over the network or adds content from timers is in the frame; a change within 64 pixels square, such as a caret or a spinner, does not restart a hold unless it is in a Chrome tab strip or toolbar after such a commit, and typing or a key that does not commit counts a change past a finer tolerance, so one character ends the wait, and its result text names the windows it opened, closed or brought to the front; pixel x,y are read in the last screenshot the session returned and mapped to the screen by shuttle. On a cbcore box a key, hotkey or type without an element goes to the desktop through XTest when the frontmost window is the active one and holds the input focus, no popup of another client is on the screen, the keyboard is not grabbed unless by a popup of that app, and the core keyboard is the only master keyboard; a key or hotkey without an element goes there too while no application window is on the screen (only the desktop and panels), where typing fails instead, and while no window manager runs yet, as for a few seconds after a desktop start, such input first waits for one, at most 10 s, then fails; an element click is a pixel click at the element’s centre when the element’s pixels still match the last computer_state, and the top window there is its window while that is active and nothing grabs the pointer, or a popup of its app around a menu or list row; typing into a web page element is such a click followed by desktop typing; any other input addressed to a pid runs in a cua-driver session of its own that ends right after, because the driver’s per-session master keyboard would outlive the call and Chrome drops core keyboard input (XTest, VNC) while one exists. On a cbcore box the box’s own agent asks a tier router before each turn: gw’s decisions model (POST <GW_BASE_URL>/decisions, typesafe/jev-1.14, answered only by a dated jev-1.14 build (typesafe/jev-1.14-YYYYMMDD; one other than the tested -20261014 is logged once), with the box user’s key through the egress proxy, 2 s timeout) with the tier prompt image/desk/route.json, whose GUI tier is plan. A route with confidence of at least 0.8 leads the turn’s request with one line (Router: this task is tier …) and moves that tier’s tools to the front of the offered list; a weaker route or a failed call leaves the choice to the model. Every decision is a system note in the transcript (system notes stay out of the model’s history). No tool exposes the router, neither over MCP nor to the box’s agent. An Upgrade or Rebase without an image stays in the box’s family (the name before :): the user’s policy image when it is of that family, else the default image when it is, else the first rebase_images entry of that family; when no offered image is of the family, latest_image is the box’s own image (no upgrade is offered) and a Rebase without an image is refused, never moved to the default image. A Rebase to an image of another family is refused (FailedPrecondition) unless it comes through the operator socket (cocoonbox-api box rebase). An image that is not offered is refused (InvalidArgument) with the list of offered images, on Create and on Rebase. A family is the name before the tag, the : after the last /. A box restores only onto an image of its own family, so a family’s template stays offered, or at least on the host, until its boxes are rebased onto another family. Restore-on-missing (Ensure on a box that has a saved head and no machine) picks its image the same way from the image that every save records in the box’s head.json; a head without one restores onto the default image./etc/cocoonbox/tools.d/*.json (file-name order; TOOLS_DIR overrides the directory) adds one script tool {name, description, bin, resources?}: name matches ^[a-z][a-z0-9_]*_run$ and names no built-in tool, description is at most 2048 characters, bin is absolute, and resources lists {uri, path, mime, description}; a file that breaks a rule or repeats a name is skipped with one log line. The tool takes script_run’s parameters and report, and shuttle’s runner runs the script with bin in place of Node; it follows script_run in tools/list and in the box agent’s tools, carries anthropic/alwaysLoad, leads with the plan tier, pulls the flow library before a run of a flows/ script and pushes after each run, and its resources follow the guide in resources/list. Each /etc/cocoonbox/routing.d/*.md (file-name order; ROUTING_DIR overrides) is appended, after a blank line, to the routing at the end of the box agent’s prompt, and, in the same order, to the cbcore MCP initialize instructions while they stay within 1600 characters: a file that would pass the cap is left out whole, and the agent logs one line the first time. A plain box and an Android box ignore both directories.cut.sh, from the previous cbcore template, with -p when the base provision changed): the image and the agent binary pass the clean gate: image/clean-gate.sh checks the cut guest after the agent step, and scripts/clean-gate the repository, its dependencies, binaries and image trees. The Mac bridge forwards a default box’s own instructions when its tools hold gui_plan.script_run (cbcore): runs one script (an ES module on the desk API) in shuttle’s runner, with Node as user in /workspace. Input: code (an ES module) or script (a file in the box), params (an object passed to the module’s exported main), timeout_s (default 90, at most 110), screenshot (default true), max_edge. Output: one text block with JSON {result, logs, error?: {kind: import|no_main|exception|timeout|stopped|exit, message}, duration_ms, exit_code?, stderr, screenshot_path?}, plus an image block taken after the run; isError when error is set. Nothing persists between runs. Model calls in scripts go to gw through the egress proxy with the box user’s key. exit_code is absent after a timeout, logs and stderr keep their first 32 KiB, a run waits up to 15 s while another run or desk action holds shuttle’s desk and then fails with desk busy, a cancelled call does not stop a run shuttle has started, and a run that has not answered 15 s past its timeout gives the desk service is not answering. The box’s own agent loop offers script_run too.desk (cbcore): one desktop call, {op, ...}, answered as JSON text: windows; snapshot {window?, pid?, text?, keys?} (the accessibility tree of a window, the focused one by default: Cache.GetItems of its app, filled in by live calls where the cache lacks children or their order, roles after W3C Core-AAM with nameless filler and panel containers left out, states with checked, values of fields, bounds of shown interactive elements); find {name, role?, window?}; read {id, text?}; press {id}; act {id, action: click|action|select|focus}; at {x, y, window?}; key {keys}, type {text}, click {x, y, button?, count?} (desktop input through cua-driver; type then waits, at most 100 ms, until the focused element of the focused window holds the end of the last typed line); focus {window}, launch {app} (cua-driver); close {window} (_NET_CLOSE_WINDOW); shot {x?, y?, w?, h?} (a PNG over MIT-SHM); screen. An AT-SPI call times out after 3 s, and its app is then skipped for 10 s with “AT-SPI peer … is not responding”. Element ids are the app’s bus name and object path. shuttle serves the same calls as desk_<op> (its wire, Desk calls), and the toolhost relays them. A snapshot node is {ref, id, parent, depth, role, name, states, description?, value?, keys?, shortcut?, mnemonic?, native_role?, bounds?}: shortcut and mnemonic are key combinations in the key vocabulary (ctrl+shift+s, alt+f; keys is the raw AT-SPI binding and Linux only), native_role is the platform role where role has no ARIA match, and states names mixed for a half-checked box (visible is Linux only).cocoonbox://desk/AGENTS.md (/opt/desk/AGENTS.md, the guide). The bridge lists the default box’s resources while its session is open (else none, and it wakes no box) and reads them from the default box.browser_* call and again if the user closes it, so it never takes the keyboard focus. A read share’s call opens no window and starts no Chrome: it reads the window its session opened with write, and fails with a read-only share opens no Chrome window: … when there is none. It closes when the MCP session closes or the agent is deleted; agent windows also close before Chrome stops for a quiesced save, so the restored session holds only the user’s windows.mcp:<clientInfo.name>@<share id>, with :<user id> added for a named share, in Agents.List; every tool call and result is written to its transcript and emitted on Session.Events (including ComputerAction), so the owner sees what a guest’s agent does. The owner’s own sessions create no agent and no transcript; they emit only ComputerAction, and their shell session and Chrome window end when the MCP session closes.Agents.End{agent_id} closes the MCP session that drives that agent (NOT_FOUND without one); Agents.End{share_id} closes every session of a share, which the api calls after a revoke, a regenerate or an expiry. The agents stay, idle.:0; the toolhost holds one input mutex for every computer_act, across all agents, so injected input from two drivers never interleaves within one action. Agents still share one keyboard focus (design §26).The api serves the lifecycle of the caller’s own boxes as MCP tools, so a local agent can list, create, wake, save, upgrade, hibernate and delete them without the app. It sits beside the per-box MCP above; tool names, input and output schemas, progress and error behaviour below are the contract between the api and the cocoonbox CLI bridge.
https://<endpoint>/cocoonbox.mcp/control, which Caddy already routes to the edge’s MCP listener (/cocoonbox.mcp/*). The edge answers control itself from the api’s service layer and never proxies it to a box; box ids always start with cb-, so the name cannot collide.Authorization: Bearer <user token>, checked like Boxes, or an OAuth access token of an agent’s grant (OAuth for agents below). Every tool acts only on the caller’s own boxes, and a grant’s only on the boxes it created. A missing or bad token gets HTTP 401 with no body. With OAuth on, the 401 carries WWW-Authenticate: Bearer resource_metadata="<endpoint>/.well-known/oauth-protected-resource/cocoonbox.mcp", scope="boxes", plus error="invalid_token" when the request sent a token; a failure of the grant store is 503. With OAuth off, /.well-known/* answers 404.initialize returns an Mcp-Session-Id. POST carries JSON-RPC. A GET for a server stream gets 405, and there are no server-initiated messages or tools/list_changed.
tools/call whose request carries params._meta.progressToken is answered as text/event-stream: zero or more notifications/progress, then the result.application/json.2025-06-18 and 2025-11-25. It answers the client’s requested version when that is one of the two, and 2025-11-25 otherwise. serverInfo is {"name": "cocoonbox-control", "version": <api version>}, and the capabilities are {"tools": {}}.Results. Every successful call returns structuredContent matching the tool’s outputSchema, plus one text content block with the same object serialized as JSON. Enum values are the proto names, lower-cased and without the prefix: states absent starting running hibernating hibernated failed; phases creating waking snapshotting saving restoring deleting done failed restoring_files.
Errors. A tool that runs but cannot do what was asked returns a result with isError: true and one text block, and no structuredContent:
box_upgrade without confirm: true: Upgrading restarts running programs; files, logins and agent history are kept. Call again with confirm: true.box_upgrade when the box already runs latest_image: Already on the latest image.box_delete without confirm: true: Deleting removes the box and stops its running programs; its last saved state is kept for 7 days, then removed. Call again with confirm: true.box_delete without box: Name the box to delete in box.box that is neither the name nor the id of one of the caller’s boxes: You have no box "<box>"; box_list lists your boxes.box_hibernate or box_save when the box is not running: The box is <state>.PHASE_FAILED: <Tool verb> failed: <detail of the failed event>.box_ensure without box when the grant created no box (a grant never reaches the user’s first box): This client has created no box yet; box_create creates one.box_create past its box cap: This client may keep <n> box[es] at most; delete one of its boxes first.; its box_create, or its box_ensure of a box that holds no memory, past its running cap: This client may run <n> box[es] at once; hibernate one of its boxes first.Only protocol problems use JSON-RPC errors: -32602 for an unknown tool or arguments that do not match the input schema, -32601 for an unknown method, and -32603 for an internal failure.
Progress. For each OperationEvent of the operation a tool waits on, the server sends one notifications/progress:
progressToken: echoed from the request.progress: the 1-based index of the event within the operation, so it always increases even when a rebase’s byte count restarts between saving and restoring. total is omitted.message: <phase>: <detail>, followed by ` (Without a progress token the server still waits; it just sends nothing until the result.
Every tool but box_list, box_create and usage takes an optional box: a box name or id of the caller’s ({"type":"string","description":"The box's name or id; without it, your first box."}). Without it a tool acts on the first box; box_delete requires it.
| Tool | Input schema | structuredContent |
|---|---|---|
box_list |
{} |
{boxes: [{box_id, name, state, image, last_saved_at_ms}]}, oldest first |
box_status |
{box} |
{box_id, name, state, image, latest_image, upgrade_available, last_saved_at_ms} |
box_ensure |
{box, "wait": boolean, default true} |
{box_id, state, operation_id}. operation_id is "" when the box was already running. With wait the result arrives when the operation is done. |
box_create |
{"name": string, required; "image": string; "wait": boolean, default true} |
{box_id, name, state, operation_id} |
box_hibernate |
{box} |
{box_id, state, operation_id} after the operation is done |
box_save |
{box} |
{box_id, last_saved_at_ms, operation_id, bytes}; bytes is the last total_bytes of the saving phase |
box_upgrade |
{box, "confirm": boolean, required}; it always targets latest_image |
{box_id, state, image, operation_id} after the operation is done |
box_delete |
{box, required; "confirm": boolean, required} |
{box_id, operation_id} after the operation is done |
usage |
{} |
{today, month, daily_cap_micros, default_cap, remaining_today_micros}, as Boxes.Usage (Model usage below) |
Field types: box_id, name, state, image, latest_image and operation_id are strings; upgrade_available is a boolean; last_saved_at_ms and bytes are integers (0 means never, or nothing moved). For a user without a box, box_status returns state: "absent" with empty strings, and box_ensure creates the box.
Tool descriptions (model-facing; the api returns these exactly):
box_status: “Show the state of your cocoonbox box: running or hibernated, its image, whether an upgrade is available, and when your files were last saved.”box_ensure: “Start or wake your cocoonbox box and wait until it is running. Waking takes a few seconds; creating a new box takes longer.”box_hibernate: “Hibernate your cocoonbox box. Running programs and memory are kept and come back on the next wake.”box_save: “Save your files, logins and agent history from the box to storage now. On a desktop box the browser closes during the save.”box_upgrade: “Move your box to the latest image of its kind. Running programs restart; files, logins and agent history are kept. Requires confirm: true.”box_list: “List your cocoonbox boxes with their names, states and images.”box_create: “Create another cocoonbox box with this name and wait until it is running. Your other boxes stay as they are; only a limited number may run at once.” Its image (“An image family, such as cbdroid for an Android phone, or an image; without it, your usual desktop.”) goes to CreateBoxRequest.image as given; without it the box runs the user’s policy image, else the default image.box_delete: “Delete one of your cocoonbox boxes. A running box is saved first; its saved state is kept for 7 days, then removed. Requires the box and confirm: true.”usage: “Show the model usage gw metered for your account, all your boxes together: requests, tokens and cost in micro-dollars today and this month (UTC), by model, with your daily cost cap (-1 unlimited) and what is left of it today. Your own model client’s tokens are not in it.”gw meters every model call per user (user_id = the owner of the user’s one gw key) and model; the api reads it, the box reports its own share, and nobody estimates an outside agent’s tokens.
Boxes.Usage{}, the control MCP tool usage, cocoonbox usage [--json] and the Kit’s BoxConnection.usage() return the caller’s usage for today (since 00:00 UTC) and month (since the 1st, 00:00 UTC): {since_ms, requests, prompt_tokens, completion_tokens, total_tokens, cost_micros, models: [{model, requests, prompt_tokens, completion_tokens, total_tokens, cost_micros}]}, with the model as the caller asked gw for it and the numbers as gw’s GET /admin/usage/users gives them. daily_cap_micros is the user’s own cap (/admin/tenants/cocoonbox-boxes/users/<id>/budget), else accounts.default_daily_cap_micros with default_cap: true; -1 is unlimited, 0 unknown. remaining_today_micros is the cap less today’s cost, at least 0, and -1 when the cap is unlimited or unknown. Each call reads gw three times (about 5 ms in all on the bench host); nothing is cached. The console’s /cb/api/me reads the same rows.TurnEnd.usage sums every model call the turn caused: the main model’s steps, the computer subagent’s, the tier router’s decision, and the calls of each gui_plan it ran. models splits it by model with the number of calls. input_tokens counts cached input too, as the wire reports it. After each completed model call the turn records a usage transcript entry (role system) with the same sum so far, so a transcript reader can price a running turn and cancel it; TurnEnd.usage is the final sum.gui_plan returns usage: {<model>: {calls, input_tokens, output_tokens, cached_tokens}} for its own model calls (decisions, vision, grounding), {} for none, and writes the same to its .runs.jsonl line. desk.mts keeps the tally of every model call it makes through shuttle in its exported usage. A saved flow keeps the usage of the run that recorded it (every call of its chain) as usage; a replay returns it as recorded_usage next to its own usage. A .runs.jsonl step keeps the decision’s pick as pick, the vision verdict as verdict, how a grounded click was checked as checked (the stages asked joined by +: jev-image for the 640x400 crop, zoom for the scaled-up crop of a small element, full for the screen with a ring at the point; else jev (<why the image check failed>)) with their scores as p, p_zoom and p_full (null when that call failed; DESK_FULL_CHECK=off in shuttle’s environment, which its runs inherit, drops full) and each decisions call of the step as served ({model, image, build, id}, the build and generation id gw passed back, null when none answered); jev and vision there are milliseconds. Every model_decide call asks typesafe/jev-1.14 and takes only an answer from a dated jev-1.14 build (typesafe/jev-1.14-YYYYMMDD).An agent the user runs adds the control MCP URL and signs in through the standard MCP authorization flow; the user approves it in the console. On with oauth.public_url in api.yaml (the issuer, an https origin); off when empty.
Discovery. One protected resource, <endpoint>/cocoonbox.mcp, covers the control MCP and every box MCP under it; a client’s resource may name it or any URL under it. GET /.well-known/oauth-protected-resource/cocoonbox.mcp, and the same document at any path under it such as .../cocoonbox.mcp/control (RFC 9728): resource <endpoint>/cocoonbox.mcp, authorization_servers [<endpoint>], scopes_supported ["boxes"]. GET /.well-known/oauth-authorization-server (RFC 8414): issuer <endpoint>, authorization_endpoint <endpoint>/cb/oauth/authorize, token_endpoint /cocoonbox.oauth/token, registration_endpoint /cocoonbox.oauth/register, revocation_endpoint /cocoonbox.oauth/revoke, code_challenge_methods_supported ["S256"], token_endpoint_auth_methods_supported ["none"], client_id_metadata_document_supported and authorization_response_iss_parameter_supported true. Caddy routes /cocoonbox.oauth/*, /.well-known/oauth-authorization-server, /.well-known/oauth-protected-resource/cocoonbox.mcp and .../cocoonbox.mcp/* to 127.0.0.1:18201 ahead of the /cocoonbox.* rule.
Clients (public only; a document or registration asking for another token endpoint auth method is refused):
client_id that is an https URL with a path is a client ID metadata document. The api fetches it (public addresses only, no redirects, 5 s, at most 64 KiB, application/json), requires its client_id to equal the URL, a client_name and 1 to 10 redirect_uris, and caches it 5 min. Two local agent CLIs (versions 2.1.296 and 0.162.1), each with a metadata document on its vendor’s host, register this way.POST /cocoonbox.oauth/register (RFC 7591, JSON) stores nothing: the client_id is dcr_ + base64url of {"n": name, "r": redirect_uris} + . + the first 16 bytes of its HMAC-SHA256 under a key the api keeps in oauth_key, so a changed id fails. A missing name is Unnamed client. A third agent CLI (version 2026.10.01) registers this way.localhost, 127.0.0.1 or ::1, or a private-use scheme with a dot (RFC 8252 §7.1); no fragment or user info. A request’s redirect_uri must equal a registered one, except that a loopback one matches on any port (RFC 8252 §7.3).Authorization. GET /cb/oauth/authorize?<request> is a console page. It needs the console session; without one it shows the sign-in form. It sends the raw query to POST /cb/api/oauth/check (JSON, console session), which answers what the page shows: the client’s name and id, the verified host of its metadata document (empty for a registered client, shown as unverified), the redirect URI’s host and whether it is loopback (shown with a warning to allow only a request the user just started on that computer), and the default caps. A client or redirect URI the api does not trust is a 400 the page shows; past those checks, response_type other than code, PKCE other than S256 with a 43-character challenge, a scope with anything but boxes, and a resource outside <endpoint>/cocoonbox.mcp go back to the client as unsupported_response_type, invalid_request, invalid_scope and invalid_target. An absent scope or resource means boxes and the whole resource. The page also says when the client already holds a live grant of the user, and that the agent cannot use the user’s saved keys or connected accounts. POST /cb/api/oauth/approve {query, max_boxes 1..16, max_running 1..max_boxes, days 1|7|30|90} (defaults 2, 1, 30) stores a code (one use, 60 s) and answers {"redirect"}: the redirect URI with code, state and iss. POST /cb/api/oauth/deny {query} answers the redirect with error=access_denied.
Tokens. POST /cocoonbox.oauth/token (form; client_id in the form or as the Basic user name):
authorization_code with code, redirect_uri (as sent to authorize), code_verifier and the same client_id makes the grant: the user, the client, its caps and an expiry days from approval. When the same client (client_id) holds a live grant of the user, the code renews that grant instead (the new caps and expiry, its boxes kept) and adds tokens to it, so a client that signs in again for a box MCP URL reaches the boxes it created.refresh_token spends the refresh token and returns a new pair. A spent refresh token sent again deletes its grant (a replay means a leak).{access_token, token_type: "Bearer", expires_in: 3600, refresh_token, scope: "boxes"}. The access token is cba_ + 64 hex and lasts 1 h, the refresh token cbr_ + 64 hex and lasts as long as the grant. Only their SHA-256 is stored. Failures are RFC 6749 errors (invalid_grant for a wrong, spent, expired or another client’s code or refresh token).POST /cocoonbox.oauth/revoke (RFC 7009) with either token and its client_id deletes the grant; it answers 200 also for an unknown token.What a grant reaches. Its access token works on the control MCP and on the box MCP of the boxes the grant created, as the grant’s user, while the grant lives and the user is enabled. The user-token check never maps a cba_ token to a user, so gRPC, the screen, the box MCP of any other box, the console, Connect’s doors, Keys, Shares and Flows answer it as a bad token. On the control MCP the tools see only the boxes the grant created (box_list, a box name or id, the first box without one), within its caps: at most max_boxes such boxes and max_running of them starting, running or hibernating. usage reports the user’s whole account, the daily cap the grant’s boxes spend from.
Agent boxes and the user’s limits. A box a grant created is an agent box (boxes.agent, users migration 0008). The grant’s caps bound it, and the user’s limits under “Client (macos) ↔ edge” do not: it does not count toward the user’s max_boxes, its Create and Ensure skip the user’s running limit, and it holds none of that limit for the user’s own boxes. The user’s max_boxes and running limit govern only the user’s own boxes, so an agent can create boxes while a user on the default max_boxes 1 keeps main. The user’s sandboxd tenant cap (max_claims) is max_boxes + the agent boxes + 1 (the Rebase overlap), rewritten when an agent box is added or removed. Spend stays bounded by the user’s gw daily cap; the number of agent boxes is bounded by the caps of each grant the user approved.
No owner credentials in agent boxes. A box a grant created gets none of its owner’s vault keys (no CBV_ entries, no COCOONBOX_VAULT), no Connect credentials (no injected grant entries or flags) and no box door key, at provisioning, on a wake, on a key or grant change and in the periodic pushes; it keeps this after its grant ends. Its programs still reach gw with the owner’s gw key and count against the owner’s daily cap. The box row carries the mark from its insert, before its machine starts, and the api reads it from the in-memory index.
The user. /cb/agents lists the grants (GET /cb/api/agents): client, verified host, approval and expiry times, last use (kept to the minute), caps and the boxes each created. DELETE /cb/api/agents/{id} revokes one; its tokens stop at once, its open box and control MCP exchanges end, and its boxes stay the user’s. A revoke at /cocoonbox.oauth/revoke and a replayed refresh token end them the same way. A grant ended this way stays ended for 10 minutes for a call that passed the token check just before the revoke. Grants the user has not seen are marked new there, and the console’s front page names them in a notice until the Agents page is opened (POST /cb/api/agents/seen).
Idle hibernate. A running box a grant created hibernates once no client call (agent gRPC, the box MCP or a screen through the edge, or an Ensure) is open and none began or ended for oauth.idle_hibernate (default 30m; 0s turns it off). A call that only watches the box is none: Session.Events, and a box MCP GET (its event stream). The api checks every minute and starts the time again at each wake. The box keeps this after its grant ends.
Idle user boxes. A user’s own running box gets the same clock with user_idle_hibernate (default 0s, off; the hosts run 2h). Once it is idle that long, it stays up while its scheduled wake is on (SleepAt ahead), while its agent lists a turn RUNNING (Agents.List), or while cocoonbox-agent offload status, run by Session.Exec with the owner’s credential, lists a session working (an image without the command has none); an answer the api cannot get within 15 s keeps it up until the next check, and the next box is asked. Otherwise the api runs a live save as an operation and, if no client came meanwhile, hibernates the box; a failed save still hibernates it (the operation ends save failed; hibernating it), since the hibernation keeps the box’s memory and disk. Work no client, agent turn or offloaded session holds, such as a job left under nohup or tmux, is paused with the box after the threshold and goes on when the box wakes; it is not lost. Any client wakes it as before (Ensure, the app, the CLI, the control MCP). The app closes its Session.Screen while its window is hidden, minimized or covered and reopens it when shown, with take-over input as it was; a screen started or reopened while its window is hidden opens once the window shows. The share page does the same while its tab is hidden, a tab hidden from the start included, so a screen open in the background keeps no box up.
Storage: the users database, migrations in api/oauth/migrations tracked in oauth_schema_migrations: oauth_key, oauth_codes, oauth_grants, oauth_tokens, oauth_boxes (which grant created which box; it goes with the box row or the grant). The agent mark is boxes.agent and stays after the grant ends, so the box keeps its exclusions and idle hibernate and stays outside max_boxes.
Each user’s saved flows (gui_plan flows and scripts in /workspace/flows) live in one library in the api, and every cbcore box of the user syncs it.
Flow: a file name and its UTF-8 content. The name is [A-Za-z0-9][A-Za-z0-9._-]*, at most 96 characters, ending in .json or .mts. Limits: at most 128 KiB per flow, 200 flows and 3 MiB per user. The library keeps per flow rev (the library rev of its last write; the library rev grows with every write and delete), sha256 (hex, of the content), size, saved_at_ms (api time), box_id and image (the box that wrote it last and the image it ran; empty box_id for a Save, whose image is the one the request names). A write whose content equals the stored one changes nothing. Otherwise the last write wins.
Storage: the state store, <uid>/flows/index and <uid>/flows/<object id>. Each object is sealed with AES-256-GCM under HKDF-SHA256(user state key, "cocoonbox flows"), with additional data flows|<uid>|index or flows|<uid>|<object id>. Removing <uid>/ removes the library.
Box door (connect.box_listen, the door of CONNECT_URL, authenticated by the machine’s door key exactly as /mcp; a request without a current key gets 401 with no body). Every request acts on the library of the machine’s owner.
| Request | Answer |
|---|---|
GET /flows |
200 {"rev": N, "flows": [{"name", "rev", "sha256", "size", "saved_at_ms", "box_id"?, "image"?}]}, newest first |
GET /flows/{name} |
200 with the content (text/plain; charset=utf-8, X-Flow-Rev, X-Flow-Sha256); 404 for a missing flow |
PUT /flows/{name} (body: the content) |
200 with the stored entry, box_id and image taken from the machine; 400 for a bad name or content that is not UTF-8; 413 for a flow or library over its limits |
cocoonbox.control.v1.Flows (proto/cocoonbox/control/v1/flows.proto, user token; a share secret is no gRPC token at the edge):
List: the entries without content, newest first, and the library rev.Export{names}: entries with content; no names means every flow, and an unknown name is NOT_FOUND.Save{flows} (what cocoonbox flows import calls; import is a Swift keyword): takes name, content and image of each flow. Every flow is checked first, and one bad name or content refuses the whole call with INVALID_ARGUMENT. A limit is RESOURCE_EXHAUSTED, naming the flow. It returns the stored entries.Delete{names}: returns the names the library held.Agent (cbcore with CONNECT_URL only):
agent.db table flow_sync(name, rev, sha256): per flow, the rev and hash that the box and the library last agreed on. Table flow_folder(id): the id of the flows folder the rows were agreed in, which the folder holds in /workspace/flows/.sync-id.gui_plan with flow, flows or find, gui_flows, and script_run of a script under flows/, and once at start; at most 3 s.
.sync-id differs from flow_folder (removed and made again, as by rm -rf /workspace/flows, or replaced by another folder), gets every library flow it lacks, as a new box does. A copy that keeps .sync-id, such as a restore, keeps its deletes. The pull marks an unmarked folder with a new id and records the folder’s id; a folder an older agent synced is marked without a pull of what it lacks..json file, the toolhost runs shuttle’s gui_plan with {reindex: true}. It rebuilds INDEX and commits the folder’s git..json flow leaves /workspace/flows by any means (IN_DELETE or IN_MOVED_FROM, which the same inotify watch sees), the toolhost drops each INDEX line whose <name>.json is gone. It rewrites INDEX in place and never creates it, so a folder being removed gains no entry. Without inotify (macOS, Windows) the toolhost drops those lines each time it lists the flows (gui_flows, gui_plan {flows: true}), one stat per INDEX line, and sees no flow write, so flows_wait never returns there.gui_plan, script_run, write_file, edit_file, shell and upload, and after every other write of a flow file (IN_CLOSE_WRITE or IN_MOVED_TO of a flow name in /workspace/flows, which the toolhost watches with inotify, again after a restore replaced the folder). It PUTs each file whose hash differs from its row. A 400 or 413 is not retried until the content changes.flows_scan ({name: sha256} of the eligible files), flows_read {name}, flows_write {name, content} (temporary file and rename), flows_remove {name}, flows_mark {id} (the folder’s .sync-id, first written as id when the folder has none; empty without a folder), flows_index, flows_wait {after} (answers the count of flow writes the toolhost saw once it passes after; the agent holds one open).CLI (cocoonbox flows, owner token like every command):
list [--json]: name, rev, size, saved time, box and image.export [NAME...] [-o FILE]: writes {"version": 1, "exported_at_ms", "flows": [{"name", "content", "sha256", "rev", "saved_at_ms", "box_id", "image"}]} to FILE, else stdout.import FILE|- [--json]: sends each flow’s name, content and image.rm NAME...: deletes them from the library.Exit codes: 0 on success, 1 on a failure.
Each user’s own API keys, sealed in the api and put on every box of the user as egress credentials (api/vault). A value goes in once and never comes back out: no answer, page, log or audit row carries it.
cocoonbox.control.v1.Keys (proto/cocoonbox/control/v1/keys.proto, user token):
Add{name, hosts, header, template, query, value}: name is an env var name, ^[A-Z_][A-Z0-9_]{0,63}$, and not one the box uses (PATH, HOME, the proxy variables and others, or a name starting GW_, COCOONBOX_, CB_, CBV_, SSL_, NODE_, LC_, LD_, XDG_, DBUS_, BASH_, GIT_ or SUDO_). hosts: 1 to 8 public host names, or *. and a domain below a public suffix; no IP, port or path. The value goes in a header (default Authorization with template Bearer {}, {} for other headers; Basic <user>:{} sends base64 of <user>:value; hop-by-hop headers, Host, Cookie and Content-Length are refused) or in the query parameter query, not both. value: one line, at most 1024 bytes. A taken name is ALREADY_EXISTS; a bad field INVALID_ARGUMENT.List: the caller’s keys without values: name, hosts, header, template, query, placeholder, hint (the last 4 characters of a value of 12 or more) and the times.Rotate{name, value}: replaces the value; the placeholder stays. An unknown name is NOT_FOUND.Delete{name}: removes the key from the store and from every box. A program that kept the value in memory can still use it, so revoke it at the vendor too.RESOURCE_EXHAUSTED. Any other failure is INTERNAL with a fixed message.On the boxes: each key is a host-only claim env entry CBV_<id> (guest: false) with the rendered value, its hosts and its header or query, gated by the placeholder: sandboxd replaces the placeholder with the value on intercepted requests to those hosts only. The placeholder keeps a vendor prefix of the value (such as sk-proj- or ghp_), then cbv_ and 32 hex digits. The guest sees only COCOONBOX_VAULT (see Box API keys). The api pushes a change to every machine of the owner at once, and every incarnation gets the full set when it is provisioned.
Console (JSON, console session): GET /cb/api/keys, POST /cb/api/keys, PUT /cb/api/keys/{name} ({value}), DELETE /cb/api/keys/{name}; the page is /cb/keys. CLI: cocoonbox keys add NAME --host HOST... [--header H] [--template T | --query P] (the value from stdin or a prompt), ls, rotate NAME, rm NAME, each with --json.
Storage: the users database, migrations in api/vault/migrations: vault_secrets (each value sealed with AES-256-GCM under HKDF-SHA256(user state key, "cocoonbox vault value"), additional data vault_secrets|<id>|<owner>|value) and vault_audit (create, rotate, delete, with the actor app or console, kept 90 days). Both go with the user row.
The api’s broker (api/connect) holds the user’s grants on external accounts and serves their MCP tools. No message, page, log or audit row carries an access token, a refresh token or an API key, and no door reads a grant back.
cocoonbox.control.v1.Connectors (proto/cocoonbox/control/v1/connectors.proto, user token):
Catalog: in order github, google-calendar, notion, linear, attio, monday, atlassian, airtable, granola, webflow, mintlify, sentry, supabase, cloudflare, posthog, stripe, paypal, zapier, composio, hubspot, slack, link, exa, context7, custom (api/connect/catalog.go). Each entry also carries mcp_url, auth (oauth: the api’s own client; mcp: MCP authorization; none: no sign-in; custom) and icon_url. available is false when the api has no OAuth client for the vendor. hubspot, slack and link take only a client registered by hand, and are left out of the catalog while the api has none (link also needs its Stripe publishable key). install_url is the GitHub App’s repository picker. grant_only marks a connector with no MCP server the api uses: Google Calendar, since the Workspace MCP preview is not enrolled. Its grant serves no tools; programs in the box will use it through egress injection (M5.2), and Connect, refresh, revoke and Activity work as for any grant. checkable marks a connector whose grant the console can check against the vendor’s user endpoint.List{with_tools}: the caller’s connectors. status is active or needs_reconnect. with_tools lists each active connector’s tools from its MCP server, with the user’s policy (POLICY_UNSPECIFIED = the default) and the effective one. When the server refuses the list, tools_error carries its answer (status and at most 300 bytes of its body), such as a Workspace MCP server that does not admit the account.Begin{connector_id}: returns <endpoint>/cb/connect?connect=<id>, which the app opens in the browser. The flow itself starts only in the console, bound to its session.Disconnect{connector_id}: revokes at the vendor (GitHub DELETE /applications/{client_id}/grant, Google oauth2/revoke, an MCP server’s revocation_endpoint per RFC 7009), then deletes the grant, sends notifications/tools/list_changed on the user’s open MCP streams and writes an audit record. revoked is false when the vendor has no revoke call or refused it; the grant is deleted either way. Unknown connector: NOT_FOUND.SetPolicy{connector_id, tool, policy}: tool is the upstream name. The default is allow for a tool with readOnlyHint, ask for every other tool.Approve{approval_id, decision}: ONCE, ALWAYS (also sets the tool to allow) or DENY. An unknown, answered, expired or other user’s approval is NOT_FOUND.Activity{connector_id, limit}: audit records, newest first, 50 by default and at most 500.SpendRequests{}: the caller’s Link spend requests in pending_approval or requires_action, as the api last read them (GET https://api.link.com/spend_requests with the grant’s token, on this call when the caller’s last read is older than 30 s, a failing user backed off up to 10 min; no reads without an active link grant). polled_at_ms is 0 before the first read and without an active grant. Amounts are in the currency’s smallest unit.Console flow (replaces the loopback of design §16.2):
POST /cb/api/connect/{connector}/begin (JSON, console session). For custom the body carries name (the connector id, ^[a-z][a-z0-9-]{0,23}$, not a catalog id), url (https), and optionally header (default Authorization) and key.key, the api lists the server’s tools with it, stores the grant and answers {"connected": "<id>"}. Without one it stores a flow (random state, PKCE S256 verifier, 10 min, one use) holding the user, the SHA-256 of the console session secret and the SHA-256 of a random 32-byte nonce. It sets the cookie cb_connect to the nonce (Path=/cocoonbox.connect/, HttpOnly, Secure, SameSite=Lax, Max-Age=600) and answers {"url": <vendor authorization URL>}. The session secret stays in cb_session (SameSite=Strict, Path=/cb/), which never reaches the callback.GET /cocoonbox.connect/callback?code&state. The api clears cb_connect and completes only when the SHA-256 of that cookie equals the flow’s nonce hash, the state is unused and unexpired, and the console session that began the flow is still live for the flow’s user. A refused nonce leaves the flow for its own browser. For a flow whose authorization server came from MCP discovery, the callback’s iss (RFC 9207) must name that server (its metadata issuer when on the same origin, else the URL the resource metadata named; a trailing / aside), and a server whose metadata sets authorization_response_iss_parameter_supported must send it; otherwise the flow is spent and refused before any token request, so a code another server answered never reaches this server’s token endpoint. It then exchanges the code (with the verifier, and resource for MCP authorization) and stores the grant. The browser goes to /cb/connect?connected=<id>, or ?error=<message> on any refusal. A GitHub App install or repository change without a state goes to ?installed=github.Authorization requests:
GITHUB_APP_CLIENT_ID), https://github.com/login/oauth/authorize, no scopes (the App’s permissions and repository selection apply).GOOGLE_CLIENT_ID), scopes openid, https://www.googleapis.com/auth/userinfo.email and https://www.googleapis.com/auth/calendar.events, access_type=offline, prompt=consent, include_granted_scopes=true.mcp entry (Notion, Linear, Attio, monday.com, Atlassian, Airtable, Granola, Webflow, Mintlify, Sentry, Supabase, Cloudflare, PostHog, Stripe, PayPal, Zapier, Composio) and custom OAuth: MCP authorization (2025-11-25). An unauthenticated initialize gets 401, and WWW-Authenticate names resource_metadata (else the RFC 9728 well-known URLs). The protected resource names the authorization server, and its resource must be on the MCP URL’s origin. The server’s metadata must offer S256. The client is our client metadata document https://<endpoint>/cocoonbox.connect/client.json when the server sets client_id_metadata_document_supported, else one registered by dynamic registration (public, token_endpoint_auth_method: none). The scope is the challenge’s scope, else scopes_supported. Every authorization and token request carries resource.https://mcp.hubspot.com) and Slack (https://mcp.slack.com/mcp): the same discovery, but with our client registered by hand at the vendor (HUBSPOT_CLIENT_ID, SLACK_CLIENT_ID) in place of a metadata document or registration. The token requests carry its secret in the form. HubSpot sends no scope (the connector’s install decides them); Slack sends the user scopes of all its MCP tools. A refresh and a revoke use the client of the current environment, so a new secret applies to existing grants.LINK_CLIENT_ID, LINK_CLIENT_SECRET, LINK_PUBLISHABLE_KEY), https://login.link.com/auth with key=<publishable key>, scopes payment_methods.agentic and userinfo:read. Token and revoke requests (/auth/token, /auth/revoke) carry Authorization: Bearer <publishable key> and the secret in the form. Refresh tokens rotate on every refresh; access tokens last 1 h.auth: none): no sign-in. begin lists the server’s tools with no credential, stores the grant and answers {"connected": "<id>"}, as does a custom server whose initialize needs no sign-in.redirect_uri=https://<endpoint>/cocoonbox.connect/callback, code_challenge_method=S256./cocoonbox.connect/* to the api’s HTTP listener (127.0.0.1:18201), ahead of the /cocoonbox.* gRPC rule.Console calls (JSON, console session): GET /cb/api/connect (catalog, connectors with tools, pending approvals); POST /cb/api/connect/{connector}/begin; PUT /cb/api/connect/{connector}/policy {tool, policy: allow|ask|off|default}; DELETE /cb/api/connect/{connector} ({"revoked"}); GET /cb/api/connect/activity[?connector=]; POST /cb/api/connect/approvals/{id}/answer {decision: once|always|deny}; POST /cb/api/connect/{connector}/check ({"account"}: the grant, refreshed when it expires within 5 min and once more after a 401, reads the vendor’s user endpoint; a second 401 sets needs_reconnect). The page is /cb/connect.
Mode B door: https://<endpoint>/cocoonbox.mcp/connect, Authorization: Bearer <user token> (401 with no body otherwise), on the edge’s MCP listener like /cocoonbox.mcp/control.
2025-11-25 or 2025-06-18; serverInfo.name cocoonbox-connect; capabilities {"tools": {"listChanged": true}}. POST answers application/json. A GET with Accept: text/event-stream holds a stream that carries notifications/tools/list_changed whenever the user’s tools change (connect, reconnect, disconnect, a policy change, a lost grant), with a keepalive comment every 25 s.<connector id>_<tool>, within ^[a-zA-Z0-9_-]{1,64}$ (a longer name is not served), with the upstream object otherwise unchanged. A tool whose policy is off is not served. An ask tool is served with readOnlyHint: false, destructiveHint: true and runs with no approval: the client’s own prompt is the gate.tools/call returns the upstream result. Refusals are results with isError: true and one text block:
Reconnect <vendor> in CocoonBox: <endpoint>/cb/connect.;off: The tool <name> is off in your CocoonBox Connect settings.;readOnlyHint) that timed out, got a 5xx or a broken answer: text starting outcome_unknown:. The broker never retries it; the agent checks with a read tool first.An unknown tool is JSON-RPC -32602.
Box door (mode A, design §16.3; the same handler with door = box): http://10.203.0.1:18091/mcp, served on connect.box_listen on the host bridge and not routed by Caddy.
CONNECT_URL. The pool rule {host 10.203.0.1, ports [18091], secret CB_CONNECT_KEY} sets Authorization from the claim’s guest: false entry CB_CONNECT_KEY = Bearer <door key>, so the guest never holds the key.connect_door_keys), with the machine and its owner; a new key replaces the machine’s older ones.box:<machine id>.An ask tool on the box door needs an approval:
isError: true with Approval required: <summary>. … and _meta: {"cocoonbox/approval": {"approval_id": "apr_…", "summary": "…"}}. The summary is the vendor, the tool and the actual arguments (k=v, each value cut at 80 bytes).ApprovalRequest{kind: "connector", approval_id: "apr_…", summary}, with the broker’s id and summary, and waits.Connectors.Approve{approval_id, decision} (or the user answers in the console), then Agents.Respond{agent_id, approval_id, approve} with the same id.{"jsonrpc":"2.0","method":"notifications/cocoonbox/approval_answered","params":{"approval_id","decision"}} (once, always or deny) on the GET streams of the user’s box door sessions, never on a mode B door. The agent waiting on that id takes it as the answer (deny declines, the others approve) and goes on without Agents.Respond; a later Agents.Respond for an id the broker reported answered succeeds and changes nothing. A box whose stream is down when the answer comes waits for Agents.Respond as before._meta: {"cocoonbox/approval_id": "apr_…"}. On deny, it reports the refusal to the model and does not call again.isError with Refused: <reason>. (not answered yet, denied, used already, expired, other arguments).Injection (M5.2): a grant whose connector injects puts guest: false claim env entries with inject on every machine of its owner. On an intercepted request to one of the hosts that carries the placeholder, sandboxd replaces the header with the value. The guest holds only the placeholders. Only pools that intercept (v23 and later) accept the entries.
| Connector | Entry | Hosts | Value | The guest sends |
|---|---|---|---|---|
github |
GH_GIT |
github.com |
Basic base64(x-access-token:<token>) |
Authorization: Basic @GH_GIT@: git, from /run/cocoonbox-github.gitconfig |
github |
GH_API |
api.github.com |
token <token> |
Authorization: token @GH_API@: gh, with GH_TOKEN=@GH_API@ from the /usr/local/bin/gh wrapper |
google-calendar |
GOOGLE_CALENDAR |
www.googleapis.com |
Bearer <token> |
Authorization: Bearer @GOOGLE_CALENDAR@, in $GOOGLE_OAUTH_ACCESS_TOKEN |
link |
LINK_API |
api.link.com |
Bearer <token> |
Authorization: Bearer cbc_link: link-cli, with LINK_ACCESS_TOKEN=cbc_link |
github grant also sets the guest-visible flag COCOONBOX_GH_GIT=1. The image’s git config file and the gh wrapper follow it.link grant sets the guest-visible COCOONBOX_CONNECT to NAME=placeholder;hosts;how records in the vault’s form: LINK_ACCESS_TOKEN=cbc_link;api.link.com;Authorization:%20Bearer%20{} LINK_NO_REFRESH=1;;. The image’s vault render exports them in box shells like the vault’s keys, and lists only records with hosts in the box agent’s key list. The box never holds a refresh token, so link-cli cannot refresh; the api refreshes, and the egress entry carries the current token.Refresh: lazy, when less than 5 min of the access token remain, or once after a 401. It is single-flight per grant: in-process, and across replicas by the grant’s row lock, re-checked under the lock. The rotated refresh token is committed before the new access token is used. invalid_grant (also GitHub’s bad_refresh_token), or a 401 after a refresh, sets needs_reconnect.
Audit (connect_audit, kept 90 days): time, user, connector, tool, door, caller (mcp:<clientInfo.name>, box:…, or the door for a disconnect), decision (allow, approved, denied, off, approval_required, needs_reconnect, disconnect), outcome (ok, error, outcome_unknown), upstream status, latency, request and response bytes, and the arguments’ hash. Never arguments or results.
Storage: PostgreSQL, the users database, migrations in api/connect/migrations tracked in connect_schema_migrations. Each grant’s secrets are sealed with AES-256-GCM under HKDF-SHA256(user state key, "cocoonbox connect grant"), additional data connect_grants|<id>|<user>|<connector> (flows: connect_flows|…). The state key is the one users.state_key_sealed holds under the api master key.
Outbound: the broker dials only public addresses (no loopback, private, link-local or 100.64/10), so a custom URL cannot reach the host.
Configuration: connect.public_url in api.yaml (an https origin; empty turns Connect off) and connect.box_listen (the box door’s ip:port on the host bridge, 10.203.0.1:18091; empty turns the box door off). The OAuth clients come from the environment: GITHUB_APP_CLIENT_ID, GITHUB_APP_CLIENT_SECRET, GITHUB_APP_SLUG, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, HUBSPOT_CLIENT_ID, HUBSPOT_CLIENT_SECRET, SLACK_CLIENT_ID, SLACK_CLIENT_SECRET.