Part of the AgentBox docs. Start at CLAUDE.md.
- Reads
/workspace/agentbox.yaml; runs declared tasks (one-shot) and services (long-running) under a DAG scheduler. Tasks transitionpending → waiting → running → done | failed | skipped; services transitionpending → waiting → starting → running → ready | unhealthy | crashed | backoff | stopped.waitingis distinct fromstartingsoblockedOncan surface inagentbox status. Restarts crashed services with exponential backoff and captures logs to/var/log/agentbox/<svc>.log. - Credential watcher (
credentials-watcher.ts, started bydaemon.tsunlessAGENTBOX_CREDENTIAL_SYNC=0— the wire form ofbox.credentialSync): polls the three agent credential files (~/.claude/.credentials.json,~/.codex/auth.json,~/.local/share/opencode/auth.json) every 15s (mtime, then sha256) and posts acredentials-updatedrelay event with the base64 blob when a shape-valid change appears (claude: non-emptyclaudeAiOauth.refreshToken; codex/opencode: non-empty JSON). The first scan posts too — self-heals a refresh that happened while the relay was down (the host's newest-wins gate makes it a no-op otherwise). Paths/shapes are drift-tested againstAGENT_SYNC_SPECS(test/credentials-watcher.test.ts). The host side is the relay'sCredentialsFanout— seehost-relay.md. needs:on any unit forms a DAG (cycles + unknown refs rejected at config load). Independent units launch in parallel.ready_when:declares a readiness probe per service:port(TCP connect to127.0.0.1:<port>by default),log_match(regex over stdout/stderr), orhttp(GET; expects 2xx by default). Probe lives inpackages/ctl/src/probe.ts.on_timeout: kill(default) re-enters the restart policy;on_timeout: mark_unhealthyleaves the process running but flags the service — the escape hatch for legitimately slow cold starts.expose: { port: <int>, as: 80 }on a service marks it as the web service (at most one;asmust be80— the only container port AgentBox reserves;RESERVED_WEB_PORTinconfig.ts/WEB_CONTAINER_PORTin@agentbox/sandbox-docker). The supervisor owns an in-process Node TCP forwarder (WebProxy,packages/ctl/src/web-proxy.ts) that binds container:80→127.0.0.1:<expose.port>, (re)pointed byapplyWebProxy()oninit/reloadand torn down instopAll— so the wizard writingagentbox.yamlpost-create +agentbox-ctl reloadactivates it with no box restart. Binding:80as non-rootvscodeworks because the image grants the node binarycap_net_bind_service(setcapinDockerfile.box). Theexposemapping rides in the status snapshot (BoxStatusServiceEntry.expose) so the host knows the web service even whenagentbox.yamllives only in the box.- Wire ops:
statusreturns{ services, tasks };task-statusreturns task list;wait-ready { timeoutMs?, units? }blocks daemon-side until all autostart units reach their satisfying state, then resolves{ ready: true }or{ ready: false, timedOut, failed };run-task { name, force? }resets a task back to pending so the scheduler reruns it (forcealso bypasses therun_onceskip). run_once:on a task (handled inTaskRunner.launch,supervisor.ts) makes a re-run a no-op when already satisfied.run_once: true→ marker keyed by a SHA-256 of the resolved command (+cwd+env) at<stateDir>/tasks/<name>(stateDirdefaults toDEFAULT_STATE_DIR = /var/lib/agentbox, the box rootfs — captured by checkpoints, never under/workspace); editing the command invalidates it.run_once: { check: <cmd> }→ run the probe first; exit 0 = skip, no marker written (right for state outside the checkpoint, e.g. a containerized DB). Marker writes happen in the childexithandler on code 0.- Replacement engine (
@agentbox/core'sreplace.ts, re-exported by@agentbox/ctl'sreplace.tswhich adds the yaml/fs loaders — kept in core so the host carry path can share it without thesandbox-core → ctl → relay → sandbox-corecycle):applyReplacementsdoes{{AGENTBOX_*}}whitelist substitution (PLACEHOLDER_KEYS) + ordered{from,to,regex?}rules. Surfaced three ways: the top-levelreplacements:block (named rule-sets, parsed inconfig.ts),agentbox-ctl render(in-box CLI,commands/render.ts), and carryreplaceEnvs/replace/rules(host-side, file-only, rendered to a temp byrenderCarryEntriesin@agentbox/sandbox-corebefore the per-provider copy — wired insandbox-docker/create.tsandsandbox-cloud/cloud-provider.ts).{{AGENTBOX_BOX_HOST}}derives as<box-name>.localhostby default, but the public-URL cloud providers (vercel/daytona/e2b) set it explicitly at boot to the real preview host (e.g.<sub>.vercel.run) vialaunchCloudCtlDaemon— seecloud-providers.md§1.0.1. {{AGENTBOX_AUTO_SECRET}}render generator (commands/render.ts→secret.ts, not the pure engine — needs crypto + fs): a render-time pass beforeapplyReplacements. Unnamed → freshrandomBytes(32).toString('base64url')per occurrence;:<name>→ generated once and persisted at<stateDir>/secrets/<name>(0600), reused across renders. State dir resolved via the sharedresolveWritableStateDir(state-dir.ts, extracted from the supervisor's marker-dir logic — try/var/lib/agentbox, fall back to<logDir>/state).- Declarative docker
image:services (config.tsparseService→parseImage→synthesizeImageCommand): a service setsimage:instead ofcommand:— either a bare ref string or a mapping{ name, ports, env, args, container_name }(container config nested underimage:); the parser synthesizes the start-or-run shell (docker container inspect→docker start+logs -f, elsedocker runwith-p/-e/args), so the runner/DAG/ready_when/restartmachinery is unchanged. Container reused by name across restarts (no auto-rm;envbaked into-e,spec.envleft unset).commandandimageare mutually exclusive (one required). - Listens on
/run/agentbox/ctl.sock(UNIX socket, newline-delimited JSON). Both the in-boxagentbox-ctlclient and host commands talk to the same socket — but the host commands shell in viadocker exec, not the bind-mounted socket: Docker Desktop / OrbStack's VM boundary breaksconnect()from the mac side, even though the file is visible. - Host-facing service control (
agentbox services, the hub box-detail panel +/api/v1/boxes/:id/services*): status isagentbox-ctl status --json; restart-one is the existing{ op: 'restart', service }wire op (agentbox-ctl restart <name>). Restart-all is a host-side loop over the service list — deliberately not a new ctl wire op, so it works on already-baked boxes (docker + cloud snapshots) with no re-bake. The provider-agnostic argv/parse plumbing is single-sourced inpackages/sandbox-core/src/box-git.ts(git ops live there too), shared by the CLI and the hub backend. - Bring-up —
agentbox-ctl bootstrap(packages/ctl/src/commands/bootstrap.ts): the single, idempotent in-box self-configure step. Cloud providers run it via ONE host exec (kickCloudBootstrap,sandbox-cloud/src/bootstrap-launch.ts) on both create and resume, replacing the three former host-driven launches (launchCloud{Ctl,Dockerd,Vnc}Daemon, now removed). It reads injected env and: (1) optionally clones/workspacefrom a leased token-bearingAGENTBOX_CLONE_URLthen scrubs origin toAGENTBOX_ORIGIN_URL(the plane / cloud-IDE path; the laptop path host-seeds and omits it); (2) launches dockerd → the ctl daemon → VNC, each only if not already live. Idempotency is load-bearing: the same kick serves create and resume, and Vercel's persistent snapshots keep daemons alive across resume — a blind relaunch would duplicate them. dockerd/VNC are best-effort; only a dead ctl daemon makes the kick exit non-zero (fatal). Flags:AGENTBOX_LAUNCH_DOCKERD=0(vercel/e2b, no DinD),AGENTBOX_VNC_ENABLED/AGENTBOX_VNC_PASSWORD. - Ordering invariant: the in-box
dockerdis launched and awaited ready before the ctl daemon — for cloud this happens insideagentbox-ctl bootstrap; for docker it's still the host-drivenlaunchDockerdDaemonthenlaunchCtlDaemon()insandbox-docker/src/ctl.ts, repeated instartBox()(best-effort; missing/emptyagentbox.yamlis fine and doesn't failcreate). The supervisor starts services the moment it's up, so adocker-based service (docker run,docker compose up) would otherwise race a not-yet-ready/var/run/docker.sock. (Docker keeps its proven per-step launch; only the cloud providers were unified onto the bootstrap.) - In-box relay: the daemon also binds an in-box endpoint on
127.0.0.1:8788(DEFAULT_BOX_RELAY_PORT; overrideAGENTBOX_BOX_RELAY_PORT) so the in-box ctl client has a symmetricAGENTBOX_RELAY_URLacross providers. For cloud boxes that endpoint is a fullmode: 'box'relay the host'sCloudBoxPollerlong-polls; for docker boxes it's a thin reverse proxy (packages/ctl/src/box-relay-forwarder.ts) that whitelistsPOST /rpc+POST /eventsand forwards toAGENTBOX_HOST_RELAY_URL(defaulthttp://host.docker.internal:8787). Keeping :8787 unbound inside the box lets a nestedagentboxrun (developing agentbox-from-inside-agentbox) claim its own host relay there. Seehost-relay.md. - Agent activity state (
working | idle | waiting | end-plan | question | prompt | compacting | error) is aggregated byStatusReporter(status-reporter.ts) and pushed to the host relay (drivesagentbox agent state/wait-for). The primary signal is hook-driven: Claude Code's managed hooks (packages/sandbox-docker/scripts/claude-managed-settings.json) callagentbox-ctl claude-state <state>; Codex/OpenCode report similarly. Two tmux-pane scrapers (tmux capture-pane -pper tick) back this up because hooks can miss:codex-scraper.tsis codex's primary source (its JSON hooks are unreliable) — a full pattern table maps the pane to a state.claude-scraper.tsis a promote-only safety net: Claude's hooks are reliable except for prompts they don't cover (MCP tool dialogs have no hook; theNotification:permission_prompthook can drop), which strand the state onworking. When the pane's bottom region shows a prompt (and no "esc to interrupt" working-hint), it callsreporter.markScreenWaiting(), which promotesworking→waitingonly — never clobbering the richer hook-drivenend-plan/question/idle. A real hook overwrites it back toworkingwhen the agent resumes, so there's no demote path. Both scrapers start incommands/daemon.ts.
- Session-id capture for restore-on-restart (
session-pointer.ts). The agent tmux session dies when a box stops/idle-pauses; on restart the host re-launches the agent resuming the same conversation, but the shared config volumes pool every box's sessions, so it can't tell which one was this box's. So the capture is lazy and in-box: the SessionStart/Stop Claude hooks pass--capture-session, andclaude-statereads the hook payload'ssession_idand writes it to~/.local/state/agentbox/claude-session(uuid-guarded). Codex exposes no resumable id (and its hooks are unreliable), soStatusReporter.setCodexStateinstead drops a presence marker~/.local/state/agentbox/codex-activethe first time codex shows activity. That dir is on the box's own writable layer (not a mounted volume), so the pointers survive stop/start + cloud pause and track/new//branch. The host reads them on restart viaprovider.exec(apps/cli/src/agent-sessions.ts). - The bin is built as CJS (
dist/bin.cjs) with all deps bundled — esbuild's ESM output poisonsrequire()from CJS deps like commander. Library entry (dist/index.js) stays ESM. - Config validation has two sources of truth that must agree: the runtime parser in
packages/ctl/src/config.ts(used by the daemon and the host pre-flight) and the JSON Schema atpackages/ctl/schema/agentbox.schema.json(used by editors).packages/ctl/test/schema-drift.test.tsfeeds the same fixtures to both and asserts they accept/reject identically. The schema can't express cross-field rules (max_ms >= initial_ms) — those cases are markedruntimeOnlyin the fixtures. - Unknown keys warn, they don't throw — the one deliberate disagreement between the two.
agentbox-ctlis baked into the box image, so a box created months ago parses anagentbox.yamlwritten against today's CLI: refusing to boot on a key it predates would make every schema addition brick every existing image. The runtime parser skips the key and records it inCtlConfig.warnings(the daemon logs them at startup;agentbox-ctl validateprints them and still exits 0). The JSON Schema stays strict — it drives editor autocomplete, where flagging a typo as you type is the point. Those fixtures are markedschemaOnly. Everything else (wrong types, bad DAG, unknownneeds:target) still fails loud. createBoxpre-validates the host'sagentbox.yamlvialoadConfigbefore any docker work; aConfigErroraborts create with the formatted message. The in-container daemon re-validates on start (defence in depth, and necessary because the file lives in the overlay and can change after create).- Editors auto-wire via
# yaml-language-server: $schema=…(Red Hat YAML extension reads it). The repo's.vscode/settings.jsonmaps the schema for in-tree files.