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 locallygit clone https://github.com/sandboard-app/sandboard.git
sandboard / operator viewWORKFLOW
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
scroll to see the handoff
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
task / in OpenShellImplement the requested change
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Container for the Plan, optional project_prompt extras, an optional sandbox override, and auto-dispatch. Not claimable work itself.
Task
The 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.
Clone 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.
MCP at /mcp: shape Projects, triage, dispatch, park / steer / halt. Operator tools only.
Worker
The agent working a card, inside a sandbox
GitHub and inference. No network path to sandboard.
Cockpit
A privileged sandbox you attach a terminal to
sandboard’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.
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.
Any item on the board. A card is either a Project or a Task.
Project
Groups Tasks, the Plan, clone_repo, optional project_prompt extras, and an optional sandbox override. Agents claim Tasks, not Projects.
Standing prompt
Optional board-wide agent policy on Settings → Agent runtime. Empty by default; hardwired protocol is separate. Injected on claim when non-empty.
project_prompt
Optional Project-only standing extras. Not seeded with the board essay on create.
Quality gates
Test/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.
Marks a Backlog card as wanting to run. The supervisor picks it up on the next tick.
Auto mode
Per-Project. Queues every claimable Backlog leaf automatically. Does not approve, answer Needs You, or unpark.
Create Task / create_task
Add 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.
Approve
On 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 changes
Sends 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.
Steer
A soft note stored for the next claim. Does not interrupt a running turn.
Park
Stops the agent but keeps the sandbox and conversation. Resume continues the same thread.
Halt
Stops the agent, clears the conversation, deletes the sandbox. The next dispatch starts clean.
Archive
Hides a Project (and its cards) from the active board via cut_scope. Not delete — toggle Show archived to browse.
Unarchive
Restore an archived Project/subtree. In-flight priors come back as Backlog (or Shaping), not Claimed/Running.
Escalate
What an agent does instead of guessing: writes a question with options and stops.
Split
What 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.
The sandbox gateway sandboard talks to over gRPC. Owns containers, network policy, and provider credentials.
Policy
A 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 spec
The 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.
Engine
Which agent CLI runs in the sandbox: cursor, claude, opencode, agy, or hermes.
Provider
A 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.
Briefing
What 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.
Lease
The claim an agent holds on a card. Expires if output stops, so another run can take the card.
Compute driver
Whatever provides the Docker-compatible API OpenShell needs: podman, Colima, Docker. Your choice, outside sandboard.
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.
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
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.
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:
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.
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.
Four things have to be true on the host. The tools named are examples, not the
only stack.
#
Role
Concretely
1
Something that runs containers
podman, Colima, or Docker
2
The OpenShell gateway
holds sandboxes, network policy, credentials
3
Model + GitHub credentials
as OpenShell providers, never baked into an image
4
A sandbox image
with 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.
OpenShell’s gateway needs a working Docker-compatible API. How you provide it is
your choice:
Driver
Typical setup
podman
podman machine start
Colima
colima start, then point the gateway at unix://$HOME/.colima/default/docker.sock
Docker Desktop / engine
Make 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.
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.
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:
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.
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.
This is the First Project loop section of Welcome/Help:
Create a Project pointed at your repo (clone_repo as owner/name).
Start its Initial plan card.
Watch it move Backlog → Running. The card shows its sandbox name.
It lands in Review with a proposed breakdown. Read it, edit it, Approve.
Start one of the resulting Tasks.
It opens a pull request and lands in Review.
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.”
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.
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.
Start the Initial plan. The agent clones, reads, and writes plan.json
proposing sibling Tasks. The card lands in Review.
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.
Start each Task (or turn auto mode on).
The worker clones, works, and opens a PR. The card lands in Review.
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.
After a Project exists, you can add more Backlog Tasks without re-running
Initial plan:
Surface
How
Board UI
Create 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.
MCP
Operator 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.
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.
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.
Needs You — an agent is stopped and waiting. Resolve these first.
Review — finished work. Sort by size and risk, not arrival time.
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.
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.
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.
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):
mergeable
What happens
MERGEABLE
Silent no-op. Card stays in Review; no catch-up work signal.
UNKNOWN
GitHub is still computing. Retry on the next sweep.
CONFLICTING
Bounce 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.
Situation
What happens
Same upstream, first MainAdvanced while Running
Steer 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 steer
Coalesce: no second park/unpark. Steer note and story refresh only when the commit sha changes.
Different upstream
No 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.
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 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.
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.
Open Cockpit from the centred chevron grip in the top bar.
Click Start.
Wait a few seconds for the supervisor to provision the sandbox.
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.
The session is a singleton record on the Board, not a file or a wrapper script:
Field
Meaning
environment
Sandbox name — defaults to sandboard-cockpit
conversation_id
Chat id the session resumes; minted if missing
status
Running, 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.
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"
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.
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.
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.
Path
Contents
/sandbox/.sandboard/mcp/mcp.json
sandboard → /sandbox/.sandboard/mcp/sandboard-mcp-stdio (retries, then socat → agent.sock)
/sandbox/.sandboard/mcp/claude_mcp.json
same shape; Claude loads it via --mcp-config
/sandbox/.gemini/config/mcp_config.json
same, for Antigravity
/sandbox/.config/opencode/opencode.jsonc
OpenCode mcp.sandboard, type: local
/sandbox/.sandboard/mcp/hermes_mcp.yaml
Board-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.
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.
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.
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.
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.”
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.
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.
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 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).
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.
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.
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.
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.
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.
sandboard stacks configuration in layers. Lower layers are operator concerns; upper
layers are what agents read at claim time. See also
Workflow and
Concepts.
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.
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.
When you create a Project (board UI, REST POST /api/items, or MCP
create_project):
Field
Stored on
Purpose
clone_repo
Project intent
Default owner/name for the Initial plan and for Tasks that omit an explicit clone line. Required on create.
sandbox_profile_id
Project row
Optional override of the board default sandbox spec. Unset means inherit Settings.
project_prompt
Project row
Optional 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.
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.
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.
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.
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:
card.model on the Task (if set) — per-card override on claim
Sandbox spec model — the winning profile for that card
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.
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 env —
profile 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.
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.
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.
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.
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.
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:
Health-checks the OpenShell gateway.
Auto-enqueues claimable Backlog leaves under Projects with auto mode on.
Claims the oldest awaiting_dispatch card within concurrency limits.
Creates or reuses a sandbox, builds a briefing from the Project→Task chain,
and starts the agent detached.
Parses the output stream for liveness; calls heartbeat / report on the
board’s behalf.
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 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.
There is no ConnectSandbox RPC. openshell sandbox connect (a human at a
terminal, not sandboard) is a chain:
GetSandbox(name) → sandbox_id
CreateSshSession(sandbox_id) → short-lived token plus gateway host/port
local ssh -tt sandbox with ProxyCommand=openshell ssh-proxy … --token …
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).
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.
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.
Inside the sandbox sandboard exports (engine-specific):
Engine
Inference env
Notes
claude
https://inference.local
Claude appends /v1/messages; --bare + --mcp-config for MCP
opencode
https://inference.local/v1
Anthropic-compatible fallback; an explicit --model provider/model selects the configured provider (for example openrouter/deepseek/deepseek-v4-flash-0731)
hermes
model.base_url=https://openrouter.ai/api/v1
Hermes’ 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.
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.
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.
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.
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.
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.
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.
card.model → sandbox spec model as --model provider/model → OpenCode default
Set Model on the spec and attach the matching provider, such as openrouter, for direct provider routing
hermes
card.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 --modelbefore-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.
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.
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).
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.
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.
Layer
Holds
Board provider antigravity
Sealed ANTIGRAVITY_ACCESS_TOKEN + refresh material (client_id / client_secret / refresh_token) from Log in with Google
Gateway
Live credential + refresh; injects placeholder env into attached sandboxes
Seat token file
Placeholder only + far-future expiry; no seat-side refresh_token
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 model
gemini-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.
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.
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.
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.”
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.
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.
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.