Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

sandboard
open source / self-hosted Get started

an open-source platform for coding agents

Run coding agents
in OpenShell sandboxes.

Sandboard coordinates coding agents. OpenShell provides the isolated execution environment. You create a Project, dispatch its Tasks, and review the resulting changes or pull request on GitHub.

run it locally git clone https://github.com/sandboard-app/sandboard.git
sandboard / operator view WORKFLOW
SANDBOXED AGENT WORKOpenShell · policy · provider
Backlog2
PLAN-01Shape the next change
TASK-04Waiting on its dependency
Running1
TASK-03Agent is working
Needs You1
TASK-02A decision is waiting
Review1
TASK-01Pull request is ready
Done12
MERGEDMerged on GitHub

what sandboard is

The board for
coding agents.

Sandboard is the part you operate: the control plane for repository work. It stores Projects and Tasks, provides the operator UI and MCP endpoint, and moves each Task from backlog to review.

OpenShell is the isolated execution runtime. Sandboard uses its gateway to create the sandbox, select the image, apply the network policy, inject provider credentials at runtime, and start the agent.

separate responsibilitiesSandboard controls lifecycle; OpenShell controls execution

how a Task moves

Sandboard starts the run.
OpenShell isolates it.

The board stores state and operator actions. Sandboard uses the OpenShell gateway to create the isolated environment, watch the run, and collect the agent’s result.

sandboard / live tracecard 01
01 / createCreate a Project and Task. 02 / provisionOpenShell creates the sandbox. 03 / observeSandboard tracks the run. 04 / reviewReview the pull request.
01 / create

Create a Project.
Describe the Task.

Create a Project, point it at a repository, and explain what you want. Sandboard creates an Initial plan that you can edit before it becomes implementation work.

Understand Projects and Tasks
02 / provision

OpenShell
creates the sandbox.

When you dispatch a Task, Sandboard asks the OpenShell gateway to provision a sandbox with the selected image and network policy, make the configured provider available, and start the agent.

See how sandboxes work
03 / observe

Sandboard
tracks the run.

Sandboard observes the agent’s output, keeps the card state current, and collects a plan, report, escalation, or split artifact. The worker cannot call the board directly.

Learn the daily workflow
04 / review

Review the
pull request.

Completed work arrives with its pull request and evidence. Sandboard can surface the change, but you merge it on GitHub.

Read the invariants

who owns what

Sandboard owns the work.
OpenShell runs the agent.

01

Sandboard

Stores Projects and Tasks, serves the UI and MCP endpoint, and runs the supervisor that owns lifecycle transitions.

02

OpenShell

Creates the sandbox and applies the image, network policy, and provider credential binding.

03

Agent

Works inside the sandbox with repository access and no network path back to Sandboard.

04

GitHub

Receives the pull request. You review and merge it; Sandboard does not merge it for you.

the complete setup

Run Sandboard with OpenShell.
Then dispatch agent work.

Start the board locally, connect an OpenShell gateway, choose a sandbox spec, and attach a provider. Without those pieces, Sandboard can show the board but it cannot run an agent.

local / first run01—03
git clone https://github.com/sandboard-app/sandboard.gitcd sandboardcargo run

Tour

One card’s life, start to finish. Nothing here needs to be installed — read it first and decide whether the loop is one you want.

The board below is a real sandboard running against a fixture. Every screenshot on this page is captured from the running UI, so what you see is what ships.

1. The board

Five columns, each asking a different question:

ColumnThe question it asks
BacklogWhat could start?
RunningWhat is an agent working on right now?
Needs YouWhat is stopped, waiting on a human?
ReviewWhat finished and is waiting for judgement?
DoneWhat landed?

Needs You sits above the columns, with answer buttons on the card — so blocked decisions are obvious without opening a drawer.

In Backlog, cards carry ⊘ waiting on chips naming what blocks them — #3 Fail closed when CI is red cannot start until #2 Surface PR checks lands. Blocked cards sort to the bottom, so the top of the column is what can start now.

2. A Project proposes its own breakdown

You do not write the task list. You create a Project, point it at a repo, and say what you want. sandboard creates one claimable card — the Initial plan — and an agent reads the repo and proposes the breakdown.

That proposal comes back as a card in Review:

Four proposed Tasks, each with a key, an intent, a definition of done, and its dependencies. You can edit any of it before approving. Approve, and those four become real cards in Backlog with the dependency edges already wired — the same ⊘ waiting on chips you saw in step 1.

This is the cheapest place to fix the plan. A card that passes every later check can still be building the wrong thing if the breakdown was wrong here.

3. An agent picks up a card

Dispatch a Backlog card (Start in the UI) and the supervisor claims it, creates a sandbox, and runs an agent inside it.

Running cards show what you would want mid-flight: which engine, how much of the run budget is left, and the sandbox name for logs.

The agent has no network path back to sandboard. It cannot see the board, cannot claim its own card, and cannot approve its own review. The supervisor speaks for it. Liveness is read from the agent’s output stream — not from a keepalive that could fire while the agent is wedged.

4. When it needs a decision, it stops

An agent that hits a genuine ambiguity does not guess and does not spin. It writes the question with options and stops. The card lands in Needs You and burns nothing until you answer.

Answering is one tap, from the band at the top of the board. The answer reaches the agent on its next turn.

5. Finished work waits in Review

The agent pushes a branch and opens a pull request. The card moves to Review carrying the PR link, the diffstat, and whichever gates it ran.

Review is sorted by size and risk, not arrival time — a large change with a failed gate sorts above a tiny clean one.

Approving in sandboard shows the PR. You merge on GitHub. When the merge lands, a webhook moves the card to Done. Siblings still in Review stay put unless GitHub reports CONFLICTING — then they bounce to Backlog for reclaim and rebase. UNKNOWN retries; repeated overlapping conflicts escalate to Needs You.

6. Seeing the shape of the work

Columns answer “what is happening”. The graph answers “what depends on what” — useful when a plan has grown past what the chips on individual cards can show.

And on a phone

On a phone you mostly see decisions that need you. If that list is short, you can leave the rest of the board alone until you are back at a desk.

Next

Concepts

The ideas the rest of the docs assume. For terms in isolation, see the Glossary; for the loop with pictures, the Tour.

The board runs the work

sandboard’s board is not a status report of work elsewhere. Changing a card is an action: dispatch claims it, Approve creates Tasks from a proposal, answering Needs You unblocks a stopped agent.

The UI and the MCP API share one state machine. Every mutation goes through Board in src/store.rs, so UI and MCP cannot drift apart.

Project and Task

One node type, two roles:

KindRole
ProjectContainer for the Plan, optional project_prompt extras, an optional sandbox override, and auto-dispatch. Not claimable work itself.
TaskThe claimable leaf. Initial plan, implementation cards, and follow-ups are all Tasks under a Project.

Tasks are flat siblings related by dependency edges, not a nested hierarchy. There is no Epic or Story layer — dependencies already say what blocks what.

Every Project gets one claimable Initial plan Task. An agent reads the repo and proposes the breakdown; you edit and Approve; the proposal becomes real cards. Same path when a card turns out too big — the agent proposes siblings and you approve those.

Operators can also add Backlog Tasks directly under an existing Project — board Create Task or MCP create_task — without re-running Initial plan. Each Task must name its clone target (owner/name) in intent/DoD. Approve still only materializes proposals and never merges.

Configuration layers

Configuration is layered; lower layers are operator setup, upper layers are what workers read at claim time. Full detail: Configuration.

LayerExamples
Process bootSANDBOARD_DATABASE_URL, compile-time Project + Task hierarchy
Board SettingsOpenShell Policies, sandbox specs, agent runtime (incl. standing prompt), Forge
Project fieldsclone_repo, optional sandbox_profile_id override
project_promptOptional Project-only standing extras
Per-card intent / DoDClone target for this card, card-specific gates, operational proof

Boot, Settings, and Project fields are operator concerns. Do not put database URLs, Policy YAML, or sandbox spec ids in standing prompts.

Board-wide agent policy is Settings → Agent runtime standing prompt (empty by default). Briefings also inject a tiny hardwired protocol (PROTOCOL_MINIMUM). project_prompt is optional Project extras — not seeded on create. Quality gates belong in standing text when you want them; name the toolchain explicitly. sandboard does not assume cargo unless standing text or the card’s DoD says so.

Operator and worker

Three roles, different reach:

RoleWhoReach
OperatorYou, and any chat agent you drive sandboard fromMCP at /mcp: shape Projects, triage, dispatch, park / steer / halt. Operator tools only.
WorkerThe agent working a card, inside a sandboxGitHub and inference. No network path to sandboard.
CockpitA privileged sandbox you attach a terminal tosandboard’s operator tools, plus inference and GitHub. No package-registry egress.

Workers cannot call sandboard. An agent that could reach the board’s MCP could approve its own review — so the supervisor calls claim / heartbeat / report on its behalf.

Cockpit uses a separate sandbox spec (and Policy) so privileged reach to the board does not share the worker’s network allow-list.

How an agent finishes

A worker has no API to call, so it finishes by writing a file into its sandbox: plan.json, report.json, escalate.json, or split.json. The supervisor picks the file up and moves the card.

An agent that hits an ambiguity stops rather than guessing. It writes escalate.json with the question and options; the card lands in Needs You and costs nothing until you answer.

Where the human stays

You merge on GitHub. Approving in sandboard surfaces the pull request. sandboard has no write access to your default branch.

Liveness is observed. The supervisor parses the agent’s output stream. There is no keepalive timer that can claim a wedged agent is alive.

The full set, with the reasoning: Invariants.

Where to go next

Glossary

Every term the rest of the docs assume, in one place.

The board

TermMeaning
CardAny item on the board. A card is either a Project or a Task.
ProjectGroups Tasks, the Plan, clone_repo, optional project_prompt extras, and an optional sandbox override. Agents claim Tasks, not Projects.
Standing promptOptional board-wide agent policy on Settings → Agent runtime. Empty by default; hardwired protocol is separate. Injected on claim when non-empty.
project_promptOptional Project-only standing extras. Not seeded with the board essay on create.
Quality gatesTest/lint commands agents run before publish. Named in the board standing prompt, project_prompt, or a card’s definition of done. sandboard does not assume cargo or any toolchain unless prose names it.
Configuration layersStacked setup: process boot → board Settings (incl. standing prompt) → Project fields → optional project_prompt → per-card intent/DoD. See Configuration.
TaskThe claimable leaf. Initial plan, implementation cards, and follow-ups are all Tasks under a Project.
Initial planThe Task sandboard creates with every Project. An agent claims it, reads the repo, and proposes the sibling Tasks.
ProposalThe breakdown an Initial plan (or a split) hands back. Editable until you Approve; Approve turns it into real cards.
Blocked byA dependency edge between Tasks. Blocked cards sort last in Backlog and render a ⊘ waiting on chip.
SwimlaneOne Project’s row on the board, with its own columns and auto-dispatch toggle.

Columns and states

Several internal states collapse into one column, because the question you ask of them is the same.

ColumnStatesThe question
BacklogbacklogWhat could start?
Runningclaimed, running, splittingWhat is an agent on right now?
Needs Youneeds_humanWhat is stopped, waiting on a person?
ReviewreviewWhat finished and wants judgement?
DonedoneWhat landed?
RetiredretiredArchived or cut. Kept for history, not deleted.

draft and shaping exist for cards still being formed.

Actions

TermWhat it does
Dispatch / StartMarks a Backlog card as wanting to run. The supervisor picks it up on the next tick.
Auto modePer-Project. Queues every claimable Backlog leaf automatically. Does not approve, answer Needs You, or unpark.
Create Task / create_taskAdd a flat Backlog Task under an existing Project (board UI or MCP) without re-running Initial plan. Parent must be a Project. Each Task names its clone target in intent/DoD. Not the same as Approve.
ApproveOn a proposal, creates the sibling Tasks. On a Review card, surfaces the PR. Does not merge. A GitHub PR APPROVED review does not Approve in sandboard.
Request changesSends a Review card back with a note that reaches the next run’s briefing. Does not restart it. Submitted GitHub CHANGES_REQUESTED / COMMENT reviews take the same Board path (pointer steer → Backlog); see Workflow.
SteerA soft note stored for the next claim. Does not interrupt a running turn.
ParkStops the agent but keeps the sandbox and conversation. Resume continues the same thread.
HaltStops the agent, clears the conversation, deletes the sandbox. The next dispatch starts clean.
ArchiveHides a Project (and its cards) from the active board via cut_scope. Not delete — toggle Show archived to browse.
UnarchiveRestore an archived Project/subtree. In-flight priors come back as Backlog (or Shaping), not Claimed/Running.
EscalateWhat an agent does instead of guessing: writes a question with options and stops.
SplitWhat an agent does when its card is bigger than one card. Proposes siblings, same as Approve.

Prefer park over halt when you want the same conversation to continue, and steer over both when the note can wait.

Roles

TermMeaning
OperatorYou, plus any chat agent you drive sandboard from. Reaches sandboard over MCP at /mcp with operator tools only.
WorkerThe agent inside a sandbox working a card. Has no network path to sandboard.
SupervisorThe part of sandboard that dispatches cards, runs sandboxes, and speaks for workers.
CockpitA durable privileged sandbox you can attach a terminal to, which reaches sandboard’s operator tools. Distinct from the operator MCP surface itself.

Execution

TermMeaning
OpenShellThe sandbox gateway sandboard talks to over gRPC. Owns containers, network policy, and provider credentials.
PolicyA named OpenShell YAML allow-list (filesystem / network) on the board. Edited in Settings → OpenShell → Policies. Applied at sandbox create and fixed for that sandbox’s life.
Sandbox specThe named recipe for a sandbox: image, CPU, memory, engine, optional model / env / prompt, attached providers, and a reference to a Policy by id. Managed in Settings → OpenShell → Sandbox specs. Spec env is non-secret (overlaid after agent_env at create; profile wins on clash); secrets belong on Providers.
EngineWhich agent CLI runs in the sandbox: cursor, claude, opencode, agy, or hermes.
ProviderA credential OpenShell holds and injects only where its endpoint/profile allows — for example OPENROUTER_API_KEY for Hermes or GH_TOKEN for GitHub. Secrets are not baked into images or persisted in sandbox workspaces.
BriefingWhat the supervisor assembles at claim time: Plan, hardwired protocol, board standing prompt, optional project_prompt, optional sandbox-spec prompt as Sandbox prompt (seat notes): (cold / Cockpit only — omitted on resume), then card intent/DoD/notes and remotes. Points at standing text and DoD for quality gates — does not invent them.
LeaseThe claim an agent holds on a card. Expires if output stops, so another run can take the card.
Compute driverWhatever provides the Docker-compatible API OpenShell needs: podman, Colima, Docker. Your choice, outside sandboard.

Protocol files

An agent has no API to sandboard, so it finishes by writing a file into its sandbox.

FileMeans
plan.jsonHere is the proposed breakdown.
report.jsonI am done; here is the PR and the diffstat.
escalate.jsonI need a decision; here is the question and the options.
split.jsonThis card is too big; here are the siblings it should become.

Quickstart

Get Sandboard running locally, then connect OpenShell before dispatching agent work. The board can start without a gateway or credentials, but it cannot run an agent until you configure them.

When you are ready for sandboxed runs, use the empty-board Welcome guide (or Help) — OpenShell + sandbox before the Project loop — then the checklist on Your first agent.

You need: a current Rust stable toolchain, and a recent Node.js if you want to build the UI.

1. Run it

git clone https://github.com/sandboard-app/sandboard.git
cd sandboard
cargo run

That serves the API, SSE, MCP, and the built UI. Bind port defaults to 8080 (SANDBOARD_PORT overrides). Open the board at whatever Host you use — UI copy and OAuth redirect URIs come from that origin, not a hardcoded loopback URL.

If web/dist does not exist yet, build the UI once:

npm --prefix web install && npm --prefix web run build

2. Create your admin

The first time you open the board it asks you to create an admin account. Until you do, the API refuses everything — there is no anonymous mode.

Pick any username and password; it is stored locally, in your board database.

3. Make something

The board starts empty. Create a Project, give it an intent, and point it at a repository (owner/name — the repo the planning agent will clone).

sandboard creates an Initial plan Task under it automatically. You now have a Project, a Task, and a board that looks like the one in the Tour — minus anything running, because nothing has been dispatched yet.

Click into the card. The detail drawer shows why this exists (the chain up to its Project), its definition of done, and the Proposed Tasks section that an agent would fill in.

Move it around. Nothing will claim it, nothing will spend money, and you cannot break anything that a restart does not fix.

Once the Project has cards, Create Task on the swimlane or Detail drawer (or MCP create_task) adds another Backlog card under that Project without re-running Initial plan. Name the clone target in intent/DoD. See Workflow.

4. Connect a chat client (optional)

You can drive the board from Cursor or Claude Code over MCP instead of the UI. sandboard must already be listening.

For an agent bringing up a fresh board (admin, OpenShell, providers, sandbox spec, first Project), point it at the public bootstrap guide first:

curl -sS "$SANDBOARD_URL/llms.txt"   # SANDBOARD_URL = the origin you open the board on

GET /llms.txt needs no auth. Source lives at llms.txt in the repo; Vite’s :5173 proxy forwards the same path in make dev-ui.

/mcp is for operators: create Projects, triage, dispatch, park, steer, approve. Worker verbs (claim, heartbeat, report, …) stay with the supervisor. The Help / Board empty guide shows {origin}/mcp from window.location.

Cursor — point .cursor/mcp.json at your board origin:

{
  "mcpServers": {
    "sandboard": {
      "type": "http",
      "url": "http://YOUR_HOST:PORT/mcp",
      "auth": { "CLIENT_ID": "sandboard-cursor", "scopes": ["mcp"] }
    }
  }
}
agent mcp login sandboard

Claude Code:

claude mcp add --transport http sandboard "$SANDBOARD_URL/mcp"

Either way a browser opens for login and consent, using the same account you just created. Tokens survive a sandboard restart, so you will not be logging in repeatedly.

If the tools list stays empty, reload the client.

Developing on it

make dev                  # watchexec rebuilds and restarts on Rust changes
make dev-ui               # Vite on :5173, proxying to :8080

make dev needs watchexec (brew install watchexec or cargo install watchexec-cli).

Next

Your first agent

The shortest path from an empty board to one real sandboxed run that opens a pull request.

This spends real money and opens pull requests. Do this once on a repo you do not mind receiving a small PR against.

Start in the product

On an empty board, Welcome to sandboard embeds the same operator guide as Help (nav → Help). That guide is the named first-run path:

  1. Connect MCP
  2. OpenShell + sandbox — Connectivity, Providers, Policies, Sandbox specs
  3. First Project loop

Work the checklist there; deep links land on Settings → OpenShell and Agent runtime. This page is the prose companion: the same order, with checks and the host-side pieces the UI does not run for you. An operator agent should start from the public /llms.txt guide (same order, API-shaped checks).

Every step has a check. Do not move on until the check passes — see why below: in this stack a half-finished step does not error, it hangs.

What you are assembling

Four things have to be true on the host. The tools named are examples, not the only stack.

#RoleConcretely
1Something that runs containerspodman, Colima, or Docker
2The OpenShell gatewayholds sandboxes, network policy, credentials
3Model + GitHub credentialsas OpenShell providers, never baked into an image
4A sandbox imagewith whatever toolchain the work needs

sandboard itself holds none of those credentials. It talks to the gateway over gRPC and the gateway injects secrets on egress, so nothing sensitive enters the sandbox.

1. A compute driver

OpenShell’s gateway needs a working Docker-compatible API. How you provide it is your choice:

DriverTypical setup
podmanpodman machine start
Colimacolima start, then point the gateway at unix://$HOME/.colima/default/docker.sock
Docker Desktop / engineMake sure the daemon is up and the gateway can reach its socket

DOCKER_HOST and friends belong to the gateway process, not to sandboard Settings.

Check:

docker info        # must succeed

The driver can stop on its own — the podman machine especially. sandboard classifies that as infrastructure rather than the card failing, so it will not burn a card’s retry budget, but it cannot prevent the outage.

2. The OpenShell gateway

Start it however your install expects (Homebrew service, systemd, …). sandboard does not spawn an openshell CLI for board traffic: src/openshell.rs talks to the gateway in-process over gRPC with client certificates.

Check:

openshell status   # expect Connected + Authenticated

Then tell sandboard how to reach it, in Settings → OpenShell → Connectivity (Welcome/Help deep-links here):

  • Gateway endpoint — often https://127.0.0.1:17670 (not sandboard’s 8080; your install may differ).
  • mTLS PEMs — CA, client cert, client key. Paste them in. They are stored encrypted in the board database (~/.config/sandboard/master.key). The API does not return private keys. sandboard does not read them from disk — upload them in Settings.

Settings (stored on the board) is the live source of truth for gateway endpoint and sealed PEMs — same split as Configuration.

Check: hit Refresh status in Settings. You want Healthy.

3. Providers

Settings → OpenShell → Providers is the credential list on the board. Sync applies it to the gateway. Which providers attach on create is chosen per Sandbox spec.

For claude sandboxes, point OpenShell’s local router at your model. OpenCode can use the same route when its model is unset, or a direct provider such as OpenRouter when its Sandbox spec has a provider/model value. Hermes uses its own endpoint-bearing OpenShell provider so it can use OpenRouter without changing the shared inference route:

openshell provider create --name vertex --type google-vertex-ai --from-gcloud-adc \
  --config VERTEX_AI_PROJECT_ID=<project> --config VERTEX_AI_REGION=global
openshell inference set --provider vertex --model claude-sonnet-4-6@default

Those agents then reach models at https://inference.local and the gateway swaps in the real credential on the way out. For Hermes, create an openrouter provider with OPENROUTER_API_KEY and attach it to the sandbox-hermes profile. Details: Sandbox.

For agy, cursor, opencode, and hermes, model selection is on the Sandbox spec (or per card at claim); sandboard passes the resolved value as agy --model …, agent --model …, opencode --model provider/model, or hermes --model …. See Configuration and Sandbox.

For GitHub, add or edit the shipped github-app provider under Settings → OpenShell → Providers (type profile under Provider types, same catalog as cursor-agent / antigravity). Set App ID, private key, and installation; Save/Sync mints GH_TOKEN onto the gateway — attach that provider on your Sandbox spec.

Check: Sync reports success and the providers you expect are listed on the gateway.

4. Policies and a sandbox image

OpenShell allow-lists are named Policies on the board (Settings → OpenShell → Policies). A Sandbox spec picks one by id — image, resources, engine, and providers live on the spec; YAML lives on the Policy. Configuration covers the split; a running sandbox keeps the policy it was created with.

A fresh board already has five seeded specs — sandbox-cursor, sandbox-agy, sandbox-claude, sandbox-opencode, sandbox-hermes — each pointed at quay.io/sandboard-app/sandbox-<engine>:latest and a matching minimal Cockpit policy. None of them is the default yet — pick one (Welcome flags this until you do). Build and push those images yourself (or point the seeded specs at wherever you host them):

make sandbox        # builds all five quay.io/sandboard-app/sandbox-<engine>:latest
make sandbox-push   # builds, then pushes all five
# Docker: CONTAINER_ENGINE=docker make sandbox
# Different registry: REGISTRY=ghcr.io/you make sandbox

From the repo root, not sandbox/ — the Containerfile is multi-stage and podman build -f sandbox/Containerfile resolves relative to wherever you run it. Each image bakes a Rust toolchain but no sandboard source or dependency cache; a card’s own cargo build/npm ci fetch crates.io/npm live, so the seeded Cockpit policies allow that egress (src/seed_policies.rs).

Then set the board’s default sandbox spec in Settings → OpenShell → Sandbox specs (Welcome/Help deep-links here) — either one of the five seeded rows or one you made. Nothing is default until you choose; the Welcome “Sandbox spec” readiness check stays red until then. Optionally set Model on the spec when using agy, cursor, opencode, or hermes (or override per card at claim); claude model routing stays on the gateway via openshell inference set as above. Specs live on the board; Configuration and Sandbox cover resolution.

Check: podman image ls | grep sandbox-, and Welcome’s “Sandbox spec” readiness check turns green.

5. Agent runtime (optional tune)

Tune concurrency / timeouts / sweep under Settings → Agent runtime if you want (Configuration).

Check: OpenShell readiness on Welcome shows Gateway/mTLS and Sandbox spec ready.

6. Run one card

This is the First Project loop section of Welcome/Help:

  1. Create a Project pointed at your repo (clone_repo as owner/name).
  2. Start its Initial plan card.
  3. Watch it move Backlog → Running. The card shows its sandbox name.
  4. It lands in Review with a proposed breakdown. Read it, edit it, Approve.
  5. Start one of the resulting Tasks.
  6. It opens a pull request and lands in Review.
  7. Merge on GitHub. The card moves to Done.

Keep max_concurrent at 1 until you have watched this work end to end.

If something takes longer than it should, it has already failed — Troubleshooting is the next page you want. Denied egress, missing credentials, and wedged relays present as silence; treat hangs as failure, not as “give it more time.”

Next

  • Workflow — steering cards day to day
  • Configuration — Policies, sandbox specs, engines, timeouts
  • Sandbox — what actually happens inside a run
  • Cockpit — a durable terminal with operator reach

Workflow

Day-to-day operation once the board is up. To enable sandboxed agents first, use the empty-board Welcome or Help OpenShell + sandbox guide, then Your first agent.

The happy path

  1. Create a Project with a clone_repo (owner/name). That repo is what the planning agent clones. sandboard creates an Initial plan Task that already names that repo.
  2. Start the Initial plan. The agent clones, reads, and writes plan.json proposing sibling Tasks. The card lands in Review.
  3. Approve it. The proposal becomes real Tasks under the Project. Approving does not auto-start them unless the Project’s auto mode is already on.
  4. Start each Task (or turn auto mode on).
  5. The worker clones, works, and opens a PR. The card lands in Review.
  6. You merge on GitHub. A webhook (or Forge polling) moves the card to Done.

Read the whole plan before approving. One good card can still be the wrong plan.

propose_breakdown is for manually replanning a Project. A card that turns out too big uses the same path in reverse: the agent writes split.json, the card goes to Review with a proposal, and Approve creates the siblings.

Adding Tasks to an existing Project

After a Project exists, you can add more Backlog Tasks without re-running Initial plan:

SurfaceHow
Board UICreate Task on the Project swimlane (when the lane is open) or in the Project Detail drawer. Title, intent, definition of done; optional blockers from sibling Tasks. Not on the empty-board Welcome path.
MCPOperator tool create_task — parent Project id, title, intent, definition_of_done, optional blocked_by. Same Board path as POST /api/items with parent.

The new card lands in Backlog, ready for Start / dispatch (or Project auto mode). Parent must be a Project — nesting under a Task is refused.

Each Task must name its clone target (owner/name) in intent and/or definition of done. When the caller omits an explicit Clone repository: line, sandboard stamps the Project default from Project intent when one is present; otherwise Remotes escalate rather than guessing.

Approve still only materializes proposals (Initial plan or split.json) and never merges. Creating a Task ad-hoc is not Approve — it is a separate human create path.

Which repo an agent clones

Agents clone the repository named in the card’s intent, definition of done, or notes. The supervisor never clones — the agent does.

Cold start (brand-new sandbox): the supervisor clears /sandbox/repo and the agent clones into that empty workdir.

Reclaim (live sandbox kept on the card): park resume and Needs You answer share one reuse path. The supervisor does not wipe /sandbox/repo; it refreshes an existing checkout in place, or ensures the directory without clearing caches when no checkout is present. The agent clones only if the workdir still has no repo.

That is why clone_repo is required when you create a Project: it lands in the Project intent and the Initial plan, so the first agent does not invent a name. Proposed Tasks should each name their clone target the same way.

Resolution at claim time is short:

card.pull_request (base/head, once a PR exists)
  → else: clone from the card's prose
  → else: escalate

An unbound card escalates rather than guessing an owner/name. Once report.json sets pull_request, that becomes the durable handle every later claim uses for resume, rebase, and request-changes.

Standing instructions and quality gates

sandboard stacks configuration in layers (Configuration):

  1. Process boot — database URL, compile-time hierarchy.
  2. Board Settings — Policies, sandbox specs, agent runtime (standing prompt), Forge/providers.
  3. Project fieldsclone_repo, optional sandbox override.
  4. project_prompt — optional Project-only standing extras.
  5. Per-card intent / DoD — clone target and card-specific work.

Boot, Settings, and Project fields are operator setup — they do not belong in standing prompts. Cold briefings stack hardwired protocol, then the board standing prompt (Settings → Agent runtime), then optional project_prompt, then card prose.

Put board-wide rules in the standing prompt when you want them: escalation, house style, and shared quality gates. Use project_prompt only when one Project needs extras. Fresh boards leave standing prompt empty.

Quality gates — commands agents must run before publish — go in the board standing prompt when they apply everywhere, or in project_prompt / card DoD when narrower. Name the commands explicitly. sandboard does not assume cargo unless those instructions name it.

Triage order

  1. Needs You — an agent is stopped and waiting. Resolve these first.
  2. Review — finished work. Sort by size and risk, not arrival time.
  3. Everything else waits for a digest (board_snapshot / board_digest).

If you are driving sandboard through a chat agent, interrupt the human for three things only: irreversible actions, an ambiguity blocking several items, and repeated failure on the same card. Otherwise summarise and let them walk away.

Dispatch and auto mode

By default the operator decides what starts. A Backlog card is inert until someone calls dispatch (Start in the UI), which sets awaiting_dispatch.

Project auto mode — the swimlane play/pause, or set_auto_dispatch — is the exception. With it on, each supervisor tick queues every claimable Backlog leaf under that Project. Pause clears awaiting_dispatch on cards still in Backlog but does not halt Claimed or Running agents. Auto mode never approves a Review, answers a Needs You, or unparks anything.

The supervisor takes the oldest claimable Backlog card with awaiting_dispatch that is not already running, subject to concurrency and gateway health.

Lease expiry, park, halt, release, and request_changes all clear awaiting_dispatch. With auto off, dispatch again; with auto on, the next tick re-queues it. Unpark clears the hold and queues the supervisor, same as Start.

Steering a card

You want toDo this
Send a reviewed card back with instructionsRequest changes — the note reaches the next run’s briefing. Does not auto-start; dispatch again.
Answer a blocked agentNeeds You — pick an option.
Stop a wedged run but keep its contextPark — stops the agent, keeps sandbox and conversation, holds until Resume.
Resume a parked cardUnpark — clears the hold; the next claim can resume the conversation.
Throw the run awayHalt — stops the agent, clears the conversation id, deletes the sandbox. Next dispatch starts clean.
Leave a note for laterSteer — stored, seen on the next claim. Does not inject mid-turn.
Auto-start claimable Backlog under a ProjectSwimlane Auto play/pause.

Prefer park over halt when the agent is stuck and you want the same conversation to continue. Prefer steer when the note can wait.

PR review feedback

When GitHub submits a PR review with state CHANGES_REQUESTED or COMMENT, sandboard treats it like human Request changes: pointer steer note, clear any proposal, move the matching card to Backlog. Same path for both review states — no auto-dispatch.

The steer note only points at the PR (url / number). It tells the next agent there is review feedback and to inspect it with gh (e.g. gh pr view / reviews). It does not summarize or paste the review body; the agent figures out the rest from the review itself.

Matching uses the card’s pr_url the same way merge completion does. Applies in Review, Needs You, and live Claimed/Running. Duplicate deliveries are safe (already-Backlog cards are not matched again).

Ingress is the same webhook endpoint: x-github-event=pull_request_review with action=submitted. Forge polling (when enabled) watches open PRs for newly submitted reviews and calls the same Board helper — first observation only seeds a per-PR cursor so historical reviews do not bounce the card.

GitHub APPROVED and dismissed reviews are board no-ops. Approving a PR on GitHub does not Approve the card in sandboard, and it does not merge. Approve and merge stay human.

When main moves

Ingress is POST /api/webhooks/github. A push to the default branch emits MainAdvanced, which does three things:

1. Merged card → Done. When a Review or Needs You card’s pr_url matches the merged PR, it completes. Webhook and polling both go through the same Board completion helper.

2. Review catch-up (scoped, CONFLICTING-only). Main advancing under a Review PR is a no-op unless GitHub reports a conflict. sandboard observes the host GitHub API mergeable field with an App installation token — not a git rebase in a sandbox — for open Review PRs on the same upstream that advanced (tip advance and same-parent sibling merge share this path):

mergeableWhat happens
MERGEABLESilent no-op. Card stays in Review; no catch-up work signal.
UNKNOWNGitHub is still computing. Retry on the next sweep.
CONFLICTINGBounce to Backlog with a binding note so a worker can reclaim and rebase.

Repeated overlapping conflict files escalate to Needs You (decomposition failure) when those file lists are present.

3. Live runs (repo-scoped steer + coalesced bounce). Claimed and Running cards on the same upstream that advanced get a binding steer note, a goal story line, and a park+unpark so the next claim carries rebase instructions. Steer alone does not inject mid-turn — the first interrupt on a Running card still happens via park+unpark. Cards on other upstreams stay Running; unbound live cards (no pull_request yet) steer only when the advanced repo matches the board default execution.agents.repo.upstream.

SituationWhat happens
Same upstream, first MainAdvanced while RunningSteer note + goal story; park+unpark; card lands in Backlog with awaiting_dispatch (sandbox environment and conversation_id preserved).
Same upstream, repeat MainAdvanced while already awaiting_dispatch from a prior steerCoalesce: no second park/unpark. Steer note and story refresh only when the commit sha changes.
Different upstreamNo steer, no bounce — card stays Running.

The goal story names the advanced ref/sha and that the live run was interrupted for rebase (distinct from manual park). The Detail drawer and SSE upserts show the steer note and story without a full page reload.

Webhook and poll responses list steered_item_ids for live-steered cards and for Review cards bounced to Backlog on CONFLICTING (deduped). Live steering does not replace Review catch-up: both fire on the same MainAdvanced, scoped to the advancing owner/name.

Local webhook forwarding

gh extension install cli/gh-webhook   # once

gh webhook forward \
  --repo=<owner/name> \
  --events=pull_request,pull_request_review,push \
  --url="$SANDBOARD_URL/api/webhooks/github"

pull_request and push cover merge → Done and main-advanced; pull_request_review covers submitted review feedback → steer (see PR review feedback). One forwarder per repo at a time. For a polling fallback instead, see Configuration.

Archive and Unarchive

Archive hides a Project (and its cards) from the active board. Nothing is deleted — toggle Show archived to browse them.

Unarchive restores the Project so it rejoins the active board. Prior states that were Claimed/Running (or other in-flight) come back as Backlog, not as live work. Confirm-gated on the board and in the Detail drawer, same pattern as Archive.

Next

Cockpit

A durable terminal inside a sandbox that can reach sandboard’s operator tools. Use it when you want an agent that can triage the board with you, rather than one working a card.

It is the third role in Concepts: narrower than you (no merges), wider than a card worker (which cannot see the board).

Prerequisites: Your first agent setup is live — a healthy OpenShell gateway and a cockpit sandbox spec in the catalog.

Start one

In the UI:

  1. Open Cockpit from the centred chevron grip in the top bar.
  2. Click Start.
  3. Wait a few seconds for the supervisor to provision the sandbox.
  4. Type in the terminal.

That is the whole path. Everything below is for automating it or understanding what it did.

Disconnecting the terminal does not stop the session — the sandbox and the conversation stay up under Start/Stop, and re-attaching resumes the same chat. Restarting sandboard does not stop it either: the supervisor reconciles, and you just open Cockpit again.

What is actually durable

The session is a singleton record on the Board, not a file or a wrapper script:

FieldMeaning
environmentSandbox name — defaults to sandboard-cockpit
conversation_idChat id the session resumes; minted if missing
statusRunning, or Parked — a hold that keeps sandbox and conversation

The terminal and any CLI attach are faces over that record. They read and mutate it through Board; they do not own lifecycle. Create sandboxes through Board APIs so inventory reconcile stays consistent.

Which image, CPU, memory, engine, and Policy (by id) Cockpit gets comes from the cockpit sandbox spec (Settings → OpenShell → Sandbox specs). Edit allow-list YAML under Policies; the spec only references it. Live policy is set at create and fixed for that sandbox. The sandbox name stays sandboard-cockpit regardless of which spec built it, so you can point Cockpit at any spec you like.

Driving it from the CLI

Same Board calls the UI makes. Scripts authenticate with HTTP Basic (Authorization: Basic base64(admin:password)), not the browser session cookie — no login step, no cookie jar to manage.

# SANDBOARD_URL = the origin you open the board on (Host / window.location.origin)
# Start (empty body; the supervisor fills in `environment`)
curl -sS -u admin:"$SANDBOARD_PASSWORD" \
  -H 'Content-Type: application/json' -d '{}' \
  "$SANDBOARD_URL/api/cockpit-session"
IntentCall
StartPOST /api/cockpit-session
InspectGET /api/cockpit-session
Hold without deletingPOST /api/cockpit-session/park
Continue after a parkPOST /api/cockpit-session/resume
Tear downDELETE /api/cockpit-session

Attach a host terminal once environment is set:

ENV=$(curl -sS -u admin:"$SANDBOARD_PASSWORD" \
  "$SANDBOARD_URL/api/cockpit-session" | jq -r '.session.environment // empty')
openshell sandbox connect "$ENV"

scripts/cockpit.sh is a thin shim over exactly these calls: start / attach / park / resume / stop.

Do not openshell sandbox delete the cockpit box while a Board session still points at it. Let DELETE /api/cockpit-session drive teardown so inventory reconcile stays consistent.

How the browser terminal works

The in-browser terminal is xterm.js over an authenticated WebSocket at /api/cockpit-attach, which opens OpenShell ExecSandboxInteractive into the Board-named environment and runs the cockpit spec’s engine. Stdin, stdout, and resize are relayed over that socket — no local SSH, because a browser cannot complete the OpenSSH ProxyCommand chain that openshell sandbox connect uses. (That chain is described in Architecture.)

Cursor launches interactive agent with --trust --approve-mcps --sandbox disabled — no --force, so tool calls still prompt for approval. Headless Cockpit chat / card runs use the same --approve-mcps (with --force and -p); without it, Cursor 2026.08+ leaves mcp.json servers unloaded (needs approval) and tools look missing even when the socat relay is up. OpenCode, Claude, and agy launch their own TUIs. Hermes launches its classic CLI (hermes --cli) in the terminal; the modern TUI is intentionally not part of the sandbox image. Hermes card/chat turns use headless hermes chat --query-file … instead.

Credentials inside the sandbox

Model auth comes from OpenShell providers. Claude uses inference.local; OpenCode and Hermes can use the attached sandboard-openrouter provider for direct OpenRouter models; OpenCode also retains the inference.local fallback. The provider injects OPENROUTER_API_KEY only into the sandbox process. No host secret is copied into the image.

MCP auth from inside the sandbox works differently from the host. Host Cursor uses browser OAuth against /mcp, and that dance does not work cleanly from inside a sandbox. So the shipped sandboard MCP entry is stdio, not HTTP: no login, no Bearer, no OAuth dance to skip.

PathContents
/sandbox/.sandboard/mcp/mcp.jsonsandboard/sandbox/.sandboard/mcp/sandboard-mcp-stdio (retries, then socatagent.sock)
/sandbox/.sandboard/mcp/claude_mcp.jsonsame shape; Claude loads it via --mcp-config
/sandbox/.gemini/config/mcp_config.jsonsame, for Antigravity
/sandbox/.config/opencode/opencode.jsoncOpenCode mcp.sandboard, type: local
/sandbox/.sandboard/mcp/hermes_mcp.yamlBoard-rendered mcp_servers; the Hermes wrapper merges it into HERMES_HOME/config.yaml

Injection happens when the sandbox becomes Ready, on POST /api/cockpit-session/mcp-cred, and on terminal attach. Do not run agent mcp login inside the sandbox unless you specifically want a separate host-style OAuth flow.

How the MCP relay works

sandboard keeps a board-owned ExecSandboxInteractive relay running socat UNIX-LISTEN:/sandbox/.sandboard/mcp/agent.sock STDIO inside the sandbox — its gRPC-piped stdin/stdout are wired straight into the same Operator MCP handler that serves the HTTP /mcp endpoint (rmcp::serve_server over the pipe). No port, no network policy entry, no Bearer to mint — same path on local Docker/Podman and remote Kubernetes, since it never leaves the sandbox’s own netns.

flowchart TB
  subgraph sandbox ["Sandbox (sandboard-cockpit)"]
    agent["Agent MCP client<br/>(reads mcp.json)"]
    socatClient["socat - UNIX-CONNECT:agent.sock"]
    sock[["agent.sock"]]
    socatServer["socat UNIX-LISTEN:agent.sock STDIO"]

    agent <--> socatClient
    socatClient <-->|"Unix domain socket"| sock
    sock <--> socatServer
  end

  subgraph host ["sandboard host process"]
    grpcClient["exec_interactive_raw()"]
    pumpLoop["pump_loop()"]
    duplexPair[["tokio::io::duplex()"]]
    serveServer["rmcp::serve_server"]
    operator["Operator"]
    board["Board"]

    grpcClient <--> pumpLoop
    pumpLoop <--> duplexPair
    duplexPair <-->|"newline-delimited<br/>JSON-RPC"| serveServer
    serveServer <--> operator
    operator <--> board
  end

  socatServer <-->|"exec's own stdin/stdout<br/>= gRPC stream"| grpcClient

The one-shot listen means agent disconnect is visible on the socket, not just inferred: socat exits, and the board re-spawns for the next connect. (Not nc — the sandbox image’s OpenBSD-netcat build accepts the connection but never forwards bytes written to its stdin after accept out to the socket, which is exactly the serve_server-response direction.) See Sandbox.

For agy the attached antigravity provider injects only an openshell:resolve:… placeholder, and attach writes that into the sandbox’s token file — never a host OAuth file. Connect once via Settings → Providers → Log in with Google so the gateway can refresh access tokens. See Sandbox → Antigravity.

Cockpit cannot merge either

The cockpit agent prepares and surfaces Review and Needs You. Approving a merge stays human, same as on the host MCP surface. Prefer escalating an ambiguous irreversible over widening what approve_review / approve_plan mean.

Troubleshooting

Everything fails as a hang

This is the single most useful thing to know about running sandboard.

A denied egress, a missing credential, a wedged relay, a stopped compute driver — none of them produce an error. They produce silence. Nothing in the sandbox stack has a reliable failure path that surfaces as a failure.

So: if something is taking longer than it should, it has already failed. Do not wait it out. Go look.

That observation shapes the code as much as the operations. Every exec sandboard issues carries a deadline, and a deadline expiring is treated as failure rather than as “maybe a bit more time.”

Where to look first

openshell logs <sandbox> -n 60     # grep for DENIED, ALLOWED, ssrf, HTTP:
openshell sandbox list             # phases; Deleting still shows up here
journalctl -u sandboard | grep 'openshell exec failed'   # board-side ExecSandbox drops

Failed ExecSandbox / interactive setup paths in src/openshell.rs emit a structured openshell exec failed line with gateway_endpoint, sandbox_name, sandbox_id, elapsed_ms, and request_id (client-generated x-request-id, overwritten by the gateway echo when response headers arrive). Use that request_id to align board logs with gateway journalctl around the same h2 stream.

The card carries its sandbox name, so you can go from a stuck card to its logs directly.

A failed card keeps its sandbox rather than deleting it. openshell logs is the tool that answers questions and a deleted sandbox answers none. Sandbox Sandbox names are attempt-scoped (sb-card-8-a2), so a retry never collides with the one being kept for inspection, and reconcile clears them at next startup.

Common causes

The compute driver stopped

The podman machine stops on its own. So does Colima, occasionally.

docker info      # if this fails, nothing below matters

sandboard classifies this as infrastructure, not as the card failing: it health-checks before claiming and pauses after an infrastructure failure rather than spending a card’s retry budget on an outage it cannot fix.

Egress was denied

The network policy is a literal allow-list, and binary paths in it are matched literally too. Git’s real remote helper is /usr/lib/git-core/git-remote-http, not git.

Grep the sandbox log for DENIED. Allow-list YAML lives in the board Policies catalog (Settings → OpenShell → Policies, or /api/openshell/policies). Sandbox specs only reference a policy by id. Edit the Policy, then create a new sandbox so the updated YAML is applied at create time.

Policy edits are not taking effect

Two separate traps:

  • Policy is immutable on a live sandbox for the filesystem and process sections. Live policy comes from the board and is set at create time — recreate the sandbox after a policy change.
  • Board Policies are authoritative. Create-form defaults select the seeded minimal policy (src/seed_policies.rs); edit egress under Settings → OpenShell → Policies (and keep the spec’s policy_id pointed at the row you mean).

The model calls hang

Do not set CLAUDE_CODE_USE_VERTEX=1 in a sandbox. It forces direct Vertex with ADC/metadata discovery, which OpenShell blocks — real GCE metadata is SSRF-hardened. Use inference.local instead; see Sandbox.

Also check the /v1 suffix: claude wants https://inference.local and appends its own path, while opencode wants https://inference.local/v1. Getting this wrong hangs rather than 404s.

An environment variable the agent needs is missing

The image’s ENV does not reach openshell sandbox exec. Baking ENV PATH=… into the Containerfile is not enough. Sandboard always passes agent_env at create; overlay non-secret seat vars on the sandbox spec’s env (Settings → Sandbox specs — profile wins on key clash). Secrets belong on Providers, not spec env. See Sandbox.

An uploaded file landed in the wrong place

sandbox upload takes a destination directory, and the destination must already exist. Uploading to /tmp/foo.py creates a directory named /tmp/foo.py with the file inside it. Upload to /tmp.

Restarting sandboard while a card is running

This is safe. The agent runs detached inside its sandbox, so it does not care that sandboard went away.

On startup reconcile lists the sandboxes sandboard labelled, matches each against its card’s environment, and picks the run back up for any card still Claimed or Running. The card stays Running; no second sandbox is created.

Two cases worth knowing:

  • The sandbox is up but nothing is running in it. The card returns to Backlog without spending a retry — that was the restart’s fault, not the card’s.
  • The gateway is not back yet. Startup waits up to 3 minutes, then logs gateway unreachable after 180s; starting without reconciling. That message is loud on purpose: treat every Running card as suspect until you have checked it.

Cards that will not start

A Backlog card is inert until someone dispatches it. Things that clear awaiting_dispatch and leave a card sitting there:

lease expiry, park, halt, release, and request_changes.

With auto mode off, dispatch again. With auto mode on, the next supervisor tick re-queues it. Auto mode never approves a Review, answers a Needs You, or unparks anything.

Also check max_concurrent — with the default of 1, a second card genuinely will not start until the first finishes.

Getting a look at the UI

npm --prefix web run shots      # → web/shots/*.png

Runs a scratch sandboard on :8081 against a fixture board and captures desktop and phone views. Your real board is untouched.

Configuration

sandboard stacks configuration in layers. Lower layers are operator concerns; upper layers are what agents read at claim time. See also Workflow and Concepts.

LayerWho sets itRole
Process bootHost / deployDatabase URL (SANDBOARD_DATABASE_URL else sqlite:sandboard.db). Hierarchy is compile-time Project + Task.
Board SettingsOperatorPolicies, sandbox specs, Agent runtime (engine, concurrency, timeouts, sweep interval, standing prompt), OpenShell gateway/providers (incl. shipped github-app), Forge, and GitHub App repo access.
Project fieldsOperatorDefault clone repo (clone_repo), optional sandbox spec override (sandbox_profile_id). Seeded into Project intent and the Initial plan.
project_promptOperatorOptional Project-only standing extras. Board-wide policy lives in Agent runtime standing prompt.
Per-card intent / DoDOperator (per Task)Card-specific work: clone target (owner/name), card-local gates, and the operational proof. Notes can override at claim time.

Boot, Settings, and Project fields are operator concerns — not agent essay text. Do not put database URLs, Policy YAML, or sandbox spec ids in standing prompts. Cold briefings stack hardwired protocol, then the board standing prompt, then optional project_prompt, then card intent/DoD.

Quality gates — test/lint commands agents should run before publish — belong in the board standing prompt when board-wide, or in project_prompt / card DoD when narrower. Name the commands explicitly (cargo test, npm test, …). sandboard does not assume cargo or any other toolchain unless those instructions name it.

Board database

Board rows live in a SQLx store. SQLite is the default; Postgres is optional, for a shared server.

SourceExample
Compiled defaultsqlite:sandboard.db
Environment overrideSANDBOARD_DATABASE_URL=postgres://sandboard:sandboard@127.0.0.1:5432/sandboard

Accepted forms:

  • SQLite — sqlite:sandboard.db, sqlite://…, sqlite::memory: (tests)
  • Postgres — postgres://… or postgresql://…

On boot sandboard opens the URL, applies versioned migrations from migrations/, and restores the board from rows.

The database URL cannot live in board Settings — Settings persist inside the database.

One-shot JSON import: if the database is empty and sandboard.json exists in the working directory, sandboard imports it once and leaves the JSON alone — archive or delete it yourself. Later boots use the database only.

Offline cargo test always uses SQLite. To exercise Postgres migrations locally, point SANDBOARD_TEST_DATABASE_URL at a reachable Postgres URL.

Environment

VariableEffect
SANDBOARD_PORTListen port (default 8080)
SANDBOARD_BIND_ADDRBind host (default 127.0.0.1; containers use 0.0.0.0)
SANDBOARD_DATABASE_URLBoard database URL (default sqlite:sandboard.db)
SANDBOARD_TEST_DATABASE_URLPostgres URL for migration tests

Cockpit’s shipped sandboard MCP entry is stdio over a local Unix socket (socat, see Cockpit) — no URL, no env var.

One host secret file: ~/.config/sandboard/master.key, which seals credentials stored on the board.

Hierarchy

Project + Task is fixed in code (schema::default_levels). There is no install-time level ladder to configure.

Project fields and project_prompt

When you create a Project (board UI, REST POST /api/items, or MCP create_project):

FieldStored onPurpose
clone_repoProject intentDefault owner/name for the Initial plan and for Tasks that omit an explicit clone line. Required on create.
sandbox_profile_idProject rowOptional override of the board default sandbox spec. Unset means inherit Settings.
project_promptProject rowOptional Project-only standing extras. Empty unless the operator supplies one.

project_prompt is not a substitute for Settings or Project fields. Keep boot-time config, OpenShell Policies, sandbox specs, and clone_repo where they belong. Board-wide standing policy is Settings → Agent runtime → standing prompt. Use project_prompt only for rules that apply inside one Project.

Per-card intent and definition of done carry the card’s clone target and any gates that apply to that card only. The supervisor never invents gates; it points agents at the board / Project standing text and the card DoD.

Agent runtime

Settings → Agent runtime (REST: /api/agent-runtime): default engine, concurrency, agent timeout, max attempts, sweep interval, and standing prompt (optional board-wide agent policy; empty by default). Card branches / sandboxes use a fixed sandboard stem (sandboard/card-*, sandboard-cockpit) — not a Settings knob. OpenShell gateway + a sandbox spec are the practical readiness gates before dispatch does anything useful.

Policies

A Policy is a named OpenShell YAML allow-list (filesystem / network). The catalog lives on the board and is edited in Settings → OpenShell → Policies (REST: /api/openshell/policies). Empty boards seed a minimal row from src/seed_policies.rs; operators add egress there as needed.

Live policy always comes from this board catalog. At sandbox create the supervisor resolves the selected policy to YAML for OpenShell. Policy is fixed for that sandbox’s life for filesystem and process sections — recreate the sandbox after a change.

Sandbox specs

A sandbox spec is the recipe for a sandbox: image, CPU, memory, engine, optional model, optional env / prompt, attached providers, and a reference to a named Policy (policy_id). Specs live on the board and are edited in Settings → OpenShell → Sandbox specs (REST: /api/sandbox-profiles). Upsert requires a known policy_id; you edit allow-list YAML under Policies, not on the spec.

Five specs come seeded — sandbox-cursor, sandbox-agy, sandbox-claude, sandbox-opencode, sandbox-hermes — one per split quay.io/sandboard-app/sandbox-<engine> image (Sandbox), each already wired to a matching minimal Cockpit policy with sandboard MCP attached. Editing a seeded row sticks; the seed only inserts what’s missing.

Model

An optional model on the spec names the model sandboard passes to agent CLIs that accept a --model flag on launch (agy, cursor / agent, opencode, hermes). OpenCode values use the provider/model format, such as openrouter/deepseek/deepseek-v4-flash-0731. Leave it unset to inherit the engine default — for agy, gemini-3.6-flash-high (DEFAULT_SEAT_MODEL); for cursor, the account default for your API key.

Resolution at claim/run:

  1. card.model on the Task (if set) — per-card override on claim
  2. Sandbox spec model — the winning profile for that card
  3. Engine default — compiled fallback when both are unset (agy only; cursor uses the account default; Hermes uses image config openai/gpt-4o-mini)

The board and card UI show the resolved value (resolved_model). Cockpit uses the same spec → default chain (no card).

claude does not read the spec model. Claude reaches models through OpenShell’s inference.local router; which model it gets is whatever you configured on the gateway with openshell inference set (see Sandbox). OpenCode does read the resolved spec/card model and passes it as --model provider/model; when it is unset, OpenCode uses its own configured default.

Hermes uses the endpoint-bearing openrouter provider type. Add an OpenShell provider instance with the OPENROUTER_API_KEY credential and attach that instance to any profile whose client uses OpenRouter. Hermes is one such client. When a card or spec model is set, sandboard passes --model; when unset, Hermes uses the image’s openai/gpt-4o-mini default.

Environment (env)

Optional string map on the spec. At sandbox create (card and Cockpit), sandboard builds env as agent_env(engine) then overlays the resolved profile’s envprofile wins on key clash. Spec env is non-secret by contract: put API URLs, tool paths, and similar seat wiring here; put credentials on Providers (attached on the same spec). The Settings editor shows that hint next to the key/value fields. See Sandbox.

Prompt (prompt)

Optional seat notes on the spec. At claim, ClaimGrant carries sandbox_prompt from the resolved profile. Cold card briefing inserts a Sandbox prompt (seat notes): section after Project prompt when non-empty; Cockpit seed briefing includes the cockpit profile’s prompt the same way. Resume briefing (conversation memory already has it) does not re-dump the sandbox prompt. See Sandbox.

Operator practice: put an API base URL in env and short usage notes in prompt (for example how to call a cluster API from the seat). Do not hardcode product-specific CLIs or cluster wiring into the binary or supervisor — keep that on the live board’s sandbox specs.

Which spec a card gets

Resolution order is documented in Sandbox. Create-form defaults select the seeded minimal policy; attach providers and pick the policy the run needs.

Cockpit

Cockpit uses the global default sandbox spec unless you set an explicit Cockpit profile under Sandbox specs. A fresh board seeds all five specs but picks none of them as default — that choice is an onboarding step (Welcome flags it red until you set one). Pick a seeded spec (or one you made) and click Set default, or Use for Cockpit to give Cockpit its own engine. That spec’s policy_id is what the sandbox gets at create. The cockpit profile’s env and prompt apply the same create-time overlay and seed-briefing rules as card specs.

OpenShell / Forge / GitHub App provider

Connectivity, providers (including the shipped github-app type that mints GH_TOKEN), provider types, Policies, Sandbox specs, Forge poll, and Repo access are board Settings — see the Settings UI and Your first agent.

Repo access walks every GitHub App installation and caches owner/repo → installation id, permissions, and last-seen time. Refresh from Settings or wait for the background job. Use the GitHub install link to add missing repositories. Token minting is unchanged: the github-app provider still uses the configured GITHUB_INSTALLATION_ID.

Architecture

One page on how the pieces fit. Present tense; code paths, not history.

One state machine

Every mutation — UI, MCP, supervisor — goes through Board in src/store.rs. Legal transitions and lifecycle invariants live in src/machine.rs. Transports (api.rs, mcp.rs, SSE) render and invoke; they do not own rules.

UI / MCP / supervisor
         │
         ▼
      Board (store.rs) ── persistence (SQLx) ── event bus (SSE)
         │
         ├── machine.rs   legal transitions
         └── model.rs     Project + Task node type

Layout

PathWhat
src/model.rsOne node type: Project (container) + Task (claimable leaf). Cockpit session singleton.
src/machine.rsLegal transitions and lifecycle invariants, for cards and the cockpit session.
src/store.rsThe board: state, persistence, event bus, derived reads.
src/api.rs src/sse.rs src/cockpit_chat.rsThe human face — REST, board SSE, cockpit chat bridge.
src/mcp.rsOperator MCP tools; the supervisor keeps worker verbs.
src/openshell.rsIn-process gRPC client to the OpenShell gateway (board endpoint + sealed mTLS); every call has a deadline.
src/supervisor.rsCard dispatch, durable cockpit start/reconcile/stop, briefing, lease sweeping.
src/engine.rsExplicit registry of agent engines — unknown ids fail loud.
Process bootDatabase URL via SANDBOARD_DATABASE_URL (else sqlite:sandboard.db). Hierarchy is compile-time Project + Task.
sandbox/Container image; minimal create-form Policy seed lives in src/seed_policies.rs (board Policies catalog is live).
web/React UI + Playwright screenshot harness.
migrations/Versioned SQLx migrations for the board store.

Sandboard’s supervisor

The supervisor is an internal part of Sandboard, not a separate service. It is the execution loop that connects Board state to the OpenShell gateway and keeps a run reconciled with the card that started it.

The supervisor:

  1. Health-checks the OpenShell gateway.
  2. Auto-enqueues claimable Backlog leaves under Projects with auto mode on.
  3. Claims the oldest awaiting_dispatch card within concurrency limits.
  4. Creates or reuses a sandbox, builds a briefing from the Project→Task chain, and starts the agent detached.
  5. Parses the output stream for liveness; calls heartbeat / report on the board’s behalf.
  6. Sweeps expired leases, and on startup reconciles live sandboxes so a sandboard restart does not orphan a running agent.

Separately, when a Board cockpit session exists, the supervisor creates or reuses the cockpit-spec sandbox (sandboard.cockpit label), starts the agent detached, reconciles across restart (keeping sandbox and conversation, like park), and stops cleanly when the session is cleared. That path never touches claim / heartbeat / report / split or the card dispatch queue — the Board’s cockpit_session fields stay authoritative.

The card worker has no network path to sandboard. The supervisor is the only caller of worker verbs on the live path.

MCP and REST

FaceTransportAudience
Operator MCP (operator tools only)MCP streamable HTTP at /mcpChat and cockpit agents (OAuth)
Host operator (operator + worker verbs)Operator::host, in-processSupervisor/host tooling and tests
Human UIREST + board SSEReact app; one-tap answers and approvals
Agent bootstrap guideGET /llms.txt (no auth)Operator agents on a fresh board
Cockpit terminalGET/WS /api/cockpit-attachxterm → ExecSandboxInteractive
Cockpit chat bridge (legacy)POST /api/cockpit-chat (SSE)Detached-agent stream-json bridge

/mcp does not expose worker verbs (claim, heartbeat, report, split, escalate, release, list_ready). Operator clients triage and dispatch; they do not run the card lifecycle.

Steer, pin, park, halt, and cut scope all want a reason, so they live in MCP. What stays one-tap in the UI is answering an escalation and approving a review.

The MCP surface is stateless on purpose: tools are request/response over SharedBoard. An in-memory session id only made clients brittle across restarts without buying server→client streams.

How the CLI attaches

There is no ConnectSandbox RPC. openshell sandbox connect (a human at a terminal, not sandboard) is a chain:

  1. GetSandbox(name)sandbox_id
  2. CreateSshSession(sandbox_id) → short-lived token plus gateway host/port
  3. local ssh -tt sandbox with ProxyCommand=openshell ssh-proxy … --token …
  4. ssh-proxy tunnels via ForwardTcp

sandboard itself never runs this chain — no local ssh, no CreateSshSession. A browser cannot complete the OpenSSH ProxyCommand chain anyway, so both the in-browser terminal and cockpit’s MCP session use ExecSandboxInteractive directly: the terminal relays it over a WebSocket (see Cockpit), and cockpit MCP wraps its stdin/stdout as an rmcp transport instead of tunneling TCP (see Cockpit).

Persistence

SQLx board store — SQLite by default, Postgres optional. Configured by board.database.url or SANDBOARD_DATABASE_URL. Mutations flush as row updates, with an optional one-shot import from sandboard.json when the database is empty. See Configuration.

Sandbox

How a sandboxed agent run works, and the operator-relevant gotchas. Assets live under sandbox/; this page is the prose companion.

How credentials reach the agent

Claude Code talks to OpenShell’s local inference router. OpenCode receives the resolved provider/model from the sandbox spec or card when one is set; its Anthropic-compatible local route remains available when no direct model is selected. Direct OpenRouter clients attach the endpoint-bearing sandboard-openrouter provider, which injects OPENROUTER_API_KEY into the sandbox and scopes that key to OpenRouter egress. Hermes is one such client; the key is sealed by OpenShell and never baked into the image or copied from the host at runtime.

openshell provider create --name sandboard-openrouter --type openrouter \
  --credential OPENROUTER_API_KEY
        │
        ▼
sandbox agent
  OPENROUTER_API_KEY=openshell:resolve:…
        │
        ▼
https://openrouter.ai/api/v1

Operator setup (once per gateway):

openshell provider create --name sandboard-openrouter --type openrouter \
  --credential OPENROUTER_API_KEY=<your-key>

Inside the sandbox sandboard exports (engine-specific):

EngineInference envNotes
claudehttps://inference.localClaude appends /v1/messages; --bare + --mcp-config for MCP
opencodehttps://inference.local/v1Anthropic-compatible fallback; an explicit --model provider/model selects the configured provider (for example openrouter/deepseek/deepseek-v4-flash-0731)
hermesmodel.base_url=https://openrouter.ai/api/v1Hermes’ built-in OpenRouter provider uses the endpoint; the attached sandboard-openrouter provider supplies OPENROUTER_API_KEY as an OpenShell placeholder and the image wrapper keeps HERMES_HOME in the sandbox

Do not set CLAUDE_CODE_USE_VERTEX=1 in the sandbox. That forces direct Vertex + ADC/metadata discovery, which OpenShell blocks (real GCE metadata is SSRF-hardened). Use the attached provider and the seeded OpenRouter policy instead.

Gateway client (gRPC + mTLS or OIDC)

src/openshell.rs talks to the gateway in-process over gRPC. Settings require an explicit auth mode:

  • mTLS — HTTPS with sealed client PEMs (board DB).
  • OIDC — HTTPS with authorization: Bearer (via openshell_core::auth::EdgeAuthInterceptor); browser PKCE uses a loopback redirect_uri (http://127.0.0.1:<port>/callback, same shape as the OpenShell CLI). Paste the callback URL into Settings — the loopback page will not load on a remote/Tailscale board. Tokens seal in the board DB; refresh uses openshell-sdk OIDC helpers.

Endpoint must be https://. The only host secret file is ~/.config/sandboard/master.key. Upload/download use exec + tar over that same channel — no openshell CLI spawn. We build the tonic channel ourselves and use openshell-core / openshell-policy for protos and YAML policy.

Agent surface

Card intent and protocol paths come from the supervisor briefing (and files under /sandbox/.sandboard). Agents finish via plan.json / report.json / escalate.json / split.json. The board is the only tracker — sandboxes do not carry a separate issue-store CLI or database.

Spec env and prompt

Sandbox specs (Settings → OpenShell → Sandbox specs) may carry optional env (string map) and prompt (seat notes). Edit them on create/edit in the UI; they round-trip on the profile API. Details and resolution live under Configuration.

Create-time env overlay

At sandbox create (card path and Cockpit), sandboard builds the OpenShell create env as:

  1. agent_env(engine) — toolchain / seat defaults the supervisor always passes (PATH, HOME, cargo/npm homes, engine inference URLs, …)
  2. Profile env overlay — keys from the resolved sandbox spec

On a key clash, the profile wins. Spec env is non-secret: API URLs, tool paths, and similar wiring belong here; secrets stay on Providers (attach them on the same spec). The Settings editor states that distinction next to the env key/value fields.

Briefing injection

When a card is claimed, the grant carries sandbox_prompt from the resolved sandbox profile. Cold card briefing() inserts a generic section after Project prompt when that value is non-empty:

Sandbox prompt (seat notes):
…

cockpit_briefing() appends the cockpit sandbox profile’s prompt the same way. resume_briefing() does not re-dump the sandbox prompt — conversation memory already has it from the cold start.

Operator practice

Put seat wiring on the live board’s sandbox specs — for example an API base URL in env and short usage notes in prompt. Do not hardcode product-specific CLIs or cluster tooling (MicroShift / oc, kubeconfig writers, PATH wrappers, post-Ready setup scripts) into the binary or supervisor; those stay out of scope of sandboard itself.

Model selection

Which model an agent run uses depends on the engine.

EngineHow model is chosenOperator configures
agycard.model → sandbox spec modelDEFAULT_SEAT_MODEL (gemini-3.6-flash-high)Optional Model on Settings → OpenShell → Sandbox specs; per-card model on claim overrides the spec
cursorcard.model → sandbox spec model → Cursor account default (no sandboard fallback)Same optional Model field; omit it to use the account default for your API key
claudeOpenShell inference.local — gateway route from openshell inference setGateway CLI once per install (see How credentials reach the agent); not the sandbox spec model field
opencodecard.model → sandbox spec model as --model provider/model → OpenCode defaultSet Model on the spec and attach the matching provider, such as openrouter, for direct provider routing
hermescard.model → sandbox spec model → Hermes image default (openai/gpt-4o-mini)Optional Model field; the openrouter provider supplies OPENROUTER_API_KEY

For engines whose CLI accepts --model on launch, sandboard injects the resolved value into the supervisor start script and Cockpit attach/chat argv. Today that is agy, cursor, opencode, and hermes. Put --model before -p when invoking agy manually — -p takes the next argv as the prompt.

Seeded sandbox specs load with model unset; agy cards then get DEFAULT_SEAT_MODEL unless you set a spec default or override a card at claim time. The card badge and Detail pane show the resolved model when known.

Image

sandbox/Containerfile builds sandboard’s own base — a minimal Red Hat UBI9 image, not the OpenShell community image — plus a Rust toolchain, split into one build target per agent engine. A shared stage installs OS packages (git, nodejs/npm, gh, gcc/make, iproute, nftables, socat) and bakes cargo/clippy, then one leaf stage per agent engine (cursor, agy, claude, opencode, hermes) installs only that engine’s CLI on top — a sandbox only ever carries the one binary it will actually run.

The toolchain is baked in, but sandboard’s own source and dependency tree are not. A card’s own cargo build/npm ci populate $CARGO_HOME (/opt/cargo), $CARGO_TARGET_DIR (/opt/cargo-target), and $NPM_CONFIG_CACHE (/opt/npm-cache) at runtime by fetching crates.io/npm live — there is no pre-baked cache to go stale every time Cargo.lock or src/ changes. This means the matching Policy has to allow that egress; see Default vs Cockpit for the seeded per-engine policies.

Why UBI9 instead of the OpenShell community image: that image bakes in every supported agent CLI, a Python/uv/cloudpickle skills venv, and Ubuntu convenience tooling sandboard never touches, regardless of which engine-specific target you build. OpenShell’s own documented minimum for a custom sandbox image is just iproute2 (required) and nftables (optional) — see examples/bring-your-own-container/Dockerfile in the OpenShell source. Building from ubi9/ubi plus exactly what sandboard needs cuts each image from ~15GB (the community-base version) to under 2GB.

OpenShift restricted SCC ignores image USER and runs as a random UID in supplementary group 0, so installer trees must not keep foreign uids (cp -a --no-preserve=ownership) and writable paths are chown sandbox:root / chgrp 0 with g=u. The named sandbox account is not a member of GID 0: OpenShell’s local podman supervisor refuses that (OCI user is a member of prohibited GID 0). Local runs use owner bits; OpenShift uses group 0 bits.

# from the repo root
make sandbox        # builds all five quay.io/sandboard-app/sandbox-<engine>:latest
make sandbox-push   # builds, then pushes all five
# or: podman build -f sandbox/Containerfile --target cursor -t quay.io/sandboard-app/sandbox-cursor:latest .
# Docker: CONTAINER_ENGINE=docker make sandbox
# Different registry: REGISTRY=ghcr.io/you make sandbox

The image flag is --from, not --image. Rebuild when you need a newer engine CLI, OS package, or Rust toolchain version — not when sandboard’s own source changes, since none of it is baked in. Matching /opt entries belong in the board Policies catalog (Settings → OpenShell → Policies): /opt/cargo, /opt/cargo-target, /opt/npm-cache need read-write (a card’s build populates them, not the image), while /opt/rust (+ that engine’s own /opt/cursor-agent, /opt/opencode, or /opt/hermes) stays read-only. The Hermes image also bakes Python 3.12 and an editable Hermes installation under /opt/hermes; its wrapper keeps runtime state under /sandbox/.hermes and merges the Board-injected MCP YAML fragment there. src/seed_policies.rs seeds one minimal Cockpit policy per engine matching each split image’s contents, including the crates.io/npm/GitHub egress a build needs — see Default vs Cockpit.

Binary identity gotcha, verified live: /opt/cargo/bin/cargo is rustup’s proxy binary — it re-execs the real cargo under /opt/rust/toolchains/<version>/bin/cargo at runtime. That’s a process exec, not a filesystem symlink, so OpenShell’s literal binary-path matching needs its own entry for the toolchain path (a glob, since the version is baked into the directory name) or a card’s first cargo build gets a 403 on crates.io even with the proxy path allowed.

Operator-relevant gotchas

Everything fails as a hang, not an error. Denied egress, missing credential, wedged relay: all silence. Every exec needs a deadline; treat silence as failure.

The image’s ENV does not reach openshell sandbox exec. Pass toolchain vars explicitly in agent_env (supervisor does this), or install wrappers on the default PATH. Baking ENV PATH=… into the Containerfile is not enough.

Upload destination is a directory (same semantics as the old CLI): uploading to /tmp/foo.py creates a directory of that name with the file inside it. Put the file in /tmp so it lands at /tmp/foo.py.

The compute driver can stop on its own. Classify that as infrastructure, not as the card failing: see is_infrastructure in the supervisor.

Workdir: cold start empties; reclaim preserves. Brand-new sandbox create clears /sandbox/repo so the agent clones into an empty tree from the Remotes briefing (origin / upstream). Reclaim of a kept sandbox — park resume and Needs You answer share the same reuse path — does not wipe /sandbox/repo. When a checkout exists, the supervisor refreshes in place: fetch the PR-target tip, prefer the local card branch (not a hard reset to origin/), and rebase only when the tree is clean. Dirty mid-run edits stay put — MainAdvanced steer asks the agent to rebase. Otherwise it ensures the directory without clearing prior contents or caches. The supervisor never clones; the agent does. /sandbox/.sandboard is always present at start with at least report.schema.json. If a clean-tree reuse rebase conflicts, the supervisor backs out and tells the agent to resolve it.

Policy is fixed for a live sandbox for filesystem and process sections. Live policy comes from the board Policies catalog and is applied at create time; policy set --wait is expensive.

Binary paths are matched literally in the policy. Lists include the real git helper paths (e.g. /usr/lib/git-core/git-remote-http).

Default vs Cockpit

Five sandbox specs come seeded — sandbox-cursor, sandbox-agy, sandbox-claude, sandbox-opencode, sandbox-hermes — one per split image, each pointed at a matching minimal Cockpit policy (cockpit-cursor, …) with sandboard MCP already attached. Seeding never sets a default — which engine to run is your call, and a fresh board’s Welcome page flags “Sandbox spec” as not ready until you make it. Pick one under Settings → OpenShell → Sandbox specs and click Set default; Cockpit inherits that default until you pick a different seeded row (or a profile you made) and click Use for Cockpit. These rows are inserts, not overwrites — editing one sticks; a re-seed on the next boot leaves your edit alone.

Attach on create starts empty for a profile you make yourself — add providers under Providers, then check them on the spec. Add sandboard MCP / package-registry / toolchain egress under Policies when you need it.

Cockpit MCP does not use host.docker.internal and does not cross the network at all. When the seat is Ready, sandboard keeps a board-owned ExecSandboxInteractive relay running one-shot socat UNIX-LISTEN:… STDIO on a local Unix socket inside the sandbox, and wires its gRPC-piped stdin/stdout straight into the same Operator MCP handler that serves host /mcp (rmcp::serve_server over the pipe). Disconnect ends the listen so the board can re-spawn; the agent’s MCP client is stdio (socat - UNIX-CONNECT:<socket>) — same path on local Docker/Podman and remote Kubernetes, since it never leaves the sandbox’s own netns. OpenShell SSH has no RemoteForward either way, so this was never a ssh -R option. Not nc: see Cockpit for why.

Antigravity / agy

Bare OpenShell generic providers do not resolve openshell:resolve:… placeholders — the egress proxy only substitutes on endpoints declared by a provider type. Sandboard ships board provider types sandbox/openshell/antigravity.yaml (auth_style: bearer, Cloud Code / Google API hosts) and sandbox/openshell/cursor-agent.yaml (CURSOR_API_KEY, Bearer on Cursor API hosts). Both seed into Settings → OpenShell → Provider types and import on provider Sync when missing. Builtin OpenShell cursor remains egress-only (no credentials).

Under Settings → OpenShell → Providers, use Log in with Google on the antigravity provider (host-mediated PKCE against Google’s Antigravity installed-app client). That seals the access token plus refresh material on the board; the gateway’s oauth2_refresh_token strategy keeps ya29 fresh. sandboard does not read the host keychain — it makes no assumptions about credentials sitting on the machine it runs on.

LayerHolds
Board provider antigravitySealed ANTIGRAVITY_ACCESS_TOKEN + refresh material (client_id / client_secret / refresh_token) from Log in with Google
GatewayLive credential + refresh; injects placeholder env into attached sandboxes
Seat token filePlaceholder only + far-future expiry; no seat-side refresh_token
Seat settings.jsonenableTelemetry: false, gcp.project / gcp.location from Board provider config ANTIGRAVITY_GCP_PROJECT / ANTIGRAVITY_GCP_LOCATION (Settings → Providers)
Seat env (agy launch)GOOGLE_CLOUD_PROJECT / GOOGLE_CLOUD_QUOTA_PROJECT set from the same Board project so they win over Vertex’s injected project — agy otherwise leaves quotaProject empty
Seat default modelgemini-3.6-flash-high (DEFAULT_SEAT_MODEL) when the spec and card omit model — requires the consumer Antigravity OAuth client from Settings → Log in with Google (the Business Cloud Code client returns Flash rows without vertexModelId)

Set a default on the sandbox-agy spec under Settings → OpenShell → Sandbox specs to avoid repeating the same model on every card. A Task’s model field on claim still wins over the spec.

Provider type YAML must list aiplatform.googleapis.com (and *-aiplatform.googleapis.com) so streamGenerateContent / OpenAI-compat chat is allowed under _provider_antigravity, not only the cockpit vertex_ai policy group.

Attach names that are not in the Providers catalog are pruned on board load.

Invariants

Properties sandboard will not trade away as the surface grows, and why each one matters. If a change would break one of these, the change is wrong.

One state machine

Every mutation — UI, MCP, supervisor — goes through Board in src/store.rs. Legal transitions live in src/machine.rs. No transport holds state-machine logic.

Why: the board has several faces and they must not drift. A rule encoded in api.rs is a rule MCP does not have, and the first time those disagree you have two products.

Workers cannot reach sandboard

The card agent gets no network path to sandboard. The supervisor calls claim / heartbeat / report on its behalf.

Why: an agent that could reach sandboard’s MCP could approve its own review. The containment is what makes the review boundary real.

Liveness is observed

The supervisor parses the agent’s output stream. There is no timer-based keepalive.

Why: a keepalive can fire while the agent is wedged. Then the lease no longer means anything — and a wedged agent holding a valid lease is exactly the case the lease exists to catch.

Merging is human

Approving in sandboard surfaces the pull request. It does not merge.

Why: merge is irreversible and needs a human. A card that passes every gate can still be building the wrong thing.

Feature branches are writable; the default branch is human-gated

Agents push sandboard/card-* and open PRs. A repository ruleset keeps the default branch owner-only.

Why: defence in depth for the rule above. The boundary should hold even if sandboard has a bug.

Everything in the sandbox stack fails as a hang

Denied egress, a missing credential, a wedged relay — all of it presents as silence, never as an error. Every exec carries a deadline, and silence is treated as failure.

Why: this is how the stack behaves, and it shapes the code thoroughly. It is why openshell.rs looks the way it does, and why “it is taking a while” usually means “it has already failed.”

Conventions that follow from these

Comments explain why, not what. A comment that restates the line below it is noise.

Describe how it works now. Docs, UI copy, MCP descriptions, and briefings should make sense to someone who never saw the previous design. Bug-history notes that justify a still-present invariant are fine; teaching the product by arguing with its past is not.

Tests name the failure they prevent, not the function they call. machine.rs holds the lifecycle invariants; other modules test what breaks silently — argv shape, shell quoting, config validation.

Working on sandboard

cargo test
cargo clippy --all-targets -- -D warnings

Both must be clean. A card’s sandbox has no pre-baked sandboard build cache — cargo/npm reach crates.io/npm live (see Sandbox) — so --offline no longer applies there; --locked still does.

Stage specific paths. git add -A has committed unintended local state here before.

Building these docs

make docs             # mdbook build → target/mdbook
make docs-serve       # http://localhost:3000

Screenshots are not committed. CI captures them from a real board against the fixture in web/ui-fixture.mjs and drops them into docs/images/ before mdBook runs, so a local make docs builds without them. To see them locally:

npm --prefix web run shots     # → web/shots/
cp web/shots/*.png docs/images/

CI publishes target/mdbook to sandboard-app/sandboard-app.github.io via a write deploy key (PAGES_DEPLOY_KEY). The org’s Deploy keys setting must stay enabled.