aiwg serve starts a local HTTP server that provides a web dashboard for managing agentic sandbox instances, terminal sessions, and human-in-the-loop (HITL) interactions.
# Install the web server feature (first time only)
aiwg features install webserver
# Local interactive terminals additionally require the native PTY feature
aiwg features install pty
# Start the server
aiwg serve
# Start on a custom port
aiwg serve --port 8080
# Start without auto-opening browser
aiwg serve --no-open
# Start in read-only mode (no PTY sessions)
aiwg serve --read-onlyaiwg features install pty builds node-pty under the AIWG user data
directory with approval limited to the node-pty lifecycle scripts. The base
global install does not run native dependency scripts. Remote sandbox terminals
and read-only mode do not require the local PTY feature.
The dashboard opens at http://127.0.0.1:7337 by default.
| Flag | Default | Description |
|---|---|---|
--port <n> |
7337 |
Port to listen on |
--bind <host> |
127.0.0.1 |
Network interface to bind |
--no-open |
(opens by default) | Skip auto-opening browser |
--read-only |
false |
Disable PTY sessions and session creation |
The maintained operator dashboard is Cockpit, a React SPA served by its own
local Bridge (default :8140). Cockpit has a dedicated documentation section —
docs/cockpit/ — covering installation, architecture,
all eleven surfaces, the session model, the Bridge API, trust posture,
stale-agent recovery, and the dev/test/mock/release patterns. The quick map:
| Tab | Purpose |
|---|---|
| Home | Live stack overview, first-run flow, and session-first entry point |
| Inventory | Host, container, Docker, and VM runtime targets with lifecycle controls |
| Running | Active work across stacks and task stop controls |
| Missions | Read-only Mission Control projection (sessions, missions, audit tail) |
| Sessions | Observe-first terminal attach, explicit drive/control, and replay posture |
| Approvals | Unified human-in-the-loop decision inbox |
| Explore | Live artifact-index status/query/rebuild and capability catalog search |
| Library | User-owned assets cloned/imported under ~/.aiwg/cockpit/library |
| Telemetry | Unified event feed and cost posture |
| Memory | Operator notes and Mission completion notes |
| Actions | Contributed actions, first-party screens, and workflows |
The standalone Terminal tab present in earlier builds was retired in #1146 phase 3. Terminal sessions now live inside the Sandbox tab as per-instance panes in a multi-pane stack — each VM, container, or agent attaches its own pane independently.
A persistent HITL drawer slides up from the bottom when any agent is blocked on human input. It polls for pending requests and lets operators respond or dismiss them without leaving the current tab.
The Sandbox tab is the single surface for managing every runtime the sandbox tracks. Layout:
- Header dropdown — sandbox selector (one entry per registered sandbox). Persists across reloads via
localStorage. - Combined Instances list — VMs (libvirt), containers (Docker), and orphan agents merged into a single list, sorted by name. Each row carries:
- Status dot (running / ready / busy / stopped / crashed)
- Runtime badge:
[VM](blue),[CT](green), or[AG](purple, for orphan agents) - Instance name + secondary line (loadout for VMs, image for containers)
- State token (
running,stopped,paused, ...) - Session-count badge
· Nwhen the sandbox has pushed a session inventory (#1151, depends onagent.sessionsevent from agentic-sandbox#192) - Runtime-aware lifecycle buttons (matrix below)
- Multi-pane terminal stack — sits above the agent grid; renders only once at least one pane is open. Click 📺 Pane on a row to attach. Multiple panes coexist; switching foreground does not disconnect.
VMs and containers have different sandbox APIs, so the dashboard renders only the buttons whose endpoints actually exist. No phantom controls.
| Runtime / state | Buttons |
|---|---|
| VM, running | ⚡ Deploy (only when no agent attached) · ↻ Restart · ⏸ Stop · ⏻ Force off · ✕ Delete |
| VM, stopped | ▶ Start · 🗑 Delete |
| Container, running | ⏸ Stop · ✕ Delete |
| Container, stopped | ▶ Start · 🗑 Delete |
| Agent (orphan) | Stop (or Start) · ↻ Reprov · ✕ Destroy |
Disk-destroying operations (Force off, Delete) confirm via window.confirm.
Each row has a 📺 Pane button that opens (or focuses) a terminal pane in the stack above. The button is always visible; it disables when no agent is attached, with a tooltip explaining the next step (e.g., "Start the VM and click ⚡ Deploy first" for a stopped VM).
This split replaces the earlier click-row-to-attach behavior, which conflated two separate actions:
- Row click — toggles selection / agent-grid filter only.
- 📺 Pane button — opens a pane.
Every open pane carries:
- Live status bar — connection dot, status text, optional CPU% / MEM / DSK chips driven by
agent.latestMetricsfrom the existing 3 s sandbox poll - ⟳ Resync — drops the cached output buffer for the current session, hard-resets xterm, refits, re-attaches. Use when rendered state has drifted from tmux state (orphaned escape codes, partial replays). Mirrors
resyncPanefrom agentic-sandbox#180. - ↺ Reconnect — re-lists sessions and re-attaches without resetting xterm. Use when the WS dropped briefly.
- ← Sessions — back to the picker (visible only when attached)
Click + New Instance in the panel header. The Runtime dropdown at the top drives which fields render below:
- VM — Name + Loadout (preset or custom compose) + vCPUs / Memory / Disk + agentshare / autostart toggles. Submits
POST /api/v1/vms. - Container — Name + Image picker fed by
GET /api/v1/container-images, with a "Custom…" free-text fallback. SubmitsPOST /api/v1/containers. Mounts / env / network UI deliberately not exposed (matches the agentic-sandbox dashboard's #178 deviation).
The active sandbox id and per-sandbox selected instance persist to localStorage:
| Key | Value |
|---|---|
aiwg:sandbox:lastSelectedSandbox |
sandbox id |
aiwg:sandbox:<id>:lastSelectedInstance |
instance name |
On reload the previous selection is restored if it still resolves; otherwise the reducer falls back to the first registered sandbox. Failure to read or write localStorage (private windows, etc.) is non-fatal.
VMs and containers poll independently every 10 s. When /api/v1/vms returns a timeout or 408 (sandbox#187 — libvirt RPC sluggishness after long uptime), the agents + containers list keeps rendering and a yellow ⚠ libvirt degraded — VM list unavailable banner appears at the top of the sidebar with a Retry button. No more empty list when one backend is sick.
The combined sidebar primarily binds rows against the event-driven sandbox registry. When a sandbox isn't pushing agent.connected events to this AIWG instance (operator-side aiwg_serve.enabled is false, registration race, etc.), the registry's agent list is empty and every Pane button stays disabled. To work around this, InstancesList polls /api/sandboxes/:id/agents/full every 10 s as a secondary source. Bound-agent lookup tries the registry first, then synthesizes a SandboxAgent from the REST FullSandboxAgent payload when the registry has nothing — so Pane lights up against the live agent inventory regardless of the event push.
When attaching a pane, AIWG sends a join_session message after shell_started so a late-attaching client sees the existing session history. Negotiation has three states:
advertised— sandbox's WS banner explicitly listsjoin_session+replay_bufferprobe— no banner present (or banner empty). AIWG sendsjoin_sessionanyway and treats anerrorreply with"unknown message"/"unsupported"/"session not found"as the negative signal — demote tounsupported, fall back to live-only.unsupported— banner present but explicitly omits the bits we care about
Each attach logs the negotiated state to aiwg serve output:
[pty-bridge] sandbox WS … (agent=…): replay enabled (banner)
[pty-bridge] sandbox WS … (agent=…): replay enabled (opportunistic — no banner advertised)
[pty-bridge] sandbox declined join_session for agent=… : "…" — demoting to unsupported
Replay activates against today's sandboxes without waiting for agentic-sandbox#190 to ship the server-hello banner.
External systems (such as agentic-sandbox — docs at docs.aiwg.io/agentic-sandbox) register with aiwg serve to appear in the dashboard and relay events.
POST /api/sandboxes/register
Content-Type: application/json
Request body:
{
"name": "my-sandbox",
"grpc_endpoint": "localhost:50051",
"ws_endpoint": "ws://localhost:8080/ws",
"http_endpoint": "http://localhost:8080",
"capabilities": ["docker", "firecracker"],
"version": "1.0.0"
}| Field | Required | Description |
|---|---|---|
name |
Yes | Human-readable sandbox name |
grpc_endpoint |
Yes | gRPC address for the sandbox |
ws_endpoint |
Yes | WebSocket address for the sandbox |
http_endpoint |
Yes | HTTP address for the sandbox |
capabilities |
No | List of supported runtimes |
version |
No | Sandbox software version |
Response (201):
{
"sandbox_id": "sandbox-a1b2c3d4",
"token": "550e8400-e29b-41d4-a716-446655440000"
}The returned token is required for all subsequent authenticated operations (WebSocket connection, deregistration, agent lifecycle).
DELETE /api/sandboxes/{id}
Authorization: Bearer <token>
Returns { "ok": true } on success. Returns 401 if the token does not match.
| Method | Path | Description |
|---|---|---|
GET |
/api/sandboxes |
List all registered sandboxes |
GET |
/api/sandboxes/:id |
Get a single sandbox summary |
GET |
/api/sandboxes/:id/agents |
List agents for a sandbox |
GET |
/api/agents |
List all agents across all sandboxes |
GET |
/api/sandboxes/:id/loadouts |
Proxy: list available loadouts |
POST |
/api/sandboxes/:id/provision |
Proxy: provision a new agent |
POST |
/api/sandboxes/:id/agents/:aid/{action} |
Proxy: agent lifecycle (start, stop, destroy, reprovision) |
DELETE |
/api/sandboxes/:id/agents/:aid |
Proxy: delete an agent |
GET |
/api/sandboxes/:id/agents/:aid/sessions |
Proxy: list an agent's terminal sessions |
POST |
/api/sandboxes/:id/agents/:aid/sessions |
Proxy: create an agent session |
DELETE |
/api/sandboxes/:id/agents/:aid/sessions/:session |
Proxy: terminate a named session |
GET |
/api/sandboxes/:id/sessions/:sessionId/screen |
Proxy: read a session's current screen buffer |
Proxied endpoints forward requests to the sandbox's own HTTP API.
Session listing is live (v2026.7.4+). The agent-session proxies above are backed by agentic-sandbox's sessions API (
#140) and host-runtime session listing (#611). Before those endpoints shipped,aiwg servereturned502as a placeholder; that path is now only a genuine-unreachability fallback. A200with an empty list means "no sessions yet" — not a wiring gap.
Each instance the sandbox reports carries transport-trust posture and
host-daemon status, which aiwg serve passes through verbatim from the
sandbox's v2 admin inventory (they are sandbox-sourced fields — aiwg serve does
not compute them):
| Field | Meaning | Example values |
|---|---|---|
transport / transport_posture |
How the client reaches the instance | Secure transport · mtls (Host/Container), Local transport · vsock (enrolled VM) |
security_posture |
Trust classification of that transport | mtls, vsock, bootstrap-pending |
host_daemon |
Host-daemon availability for host-tier instances | available, n/a (non-host tiers) |
A VM still enrolling reports bootstrap-pending (the transitional
AgentTransportPosture::bootstrap_pending() state) rather than a dead
unknown. This posture drives the Cockpit Inventory tab's per-instance
Transport / Host-daemon columns and the host ✓ · docker ✓ · vm ✓ runtime
coverage banner. Verified end-to-end against agentic-sandbox v2026.7.4
(.aiwg/testing/outputs/cockpit-7.4-transport-verify-2026-07-09.md).
Cockpit distinguishes a stopped runtime from a running runtime whose agent has
fallen out of the registry. Running Docker/container and VM rows with no
resolvable agent stay visible as agent unreachable; Inventory and Sessions
expose Reconnect instead of asking the operator to destroy or recreate the
instance.
The Cockpit Bridge recovery endpoint is:
POST /api/instances/:id/reconnect
The Bridge tries executor-owned reconnect routes first, then falls back to
docker exec <container> agent-reconnect for container rows, or delivering the
same reconnect signal through the libvirt qemu-guest-agent channel for VM rows
(#1778). Full semantics, session-survival caveats, and host-runtime guidance:
docs/cockpit/recovery.md.
After registration, the sandbox maintains a persistent WebSocket connection to push real-time events:
ws://127.0.0.1:7337/ws/sandbox/<sandbox_id>?token=<token>
The token (from the registration response) is passed as a query parameter. The server validates it on connection and on every message.
All events conform to the SandboxEvent schema:
{
"type": "agent.connected",
"sandboxId": "sandbox-a1b2c3d4",
"agentId": "agent-001",
"timestamp": "2026-04-06T12:00:00Z",
"loadout": "sdlc-full",
"aiwgFrameworks": [
{ "name": "sdlc-complete", "providers": ["claude"] }
]
}| Event Type | Description | Key Fields |
|---|---|---|
agent.connected |
Agent has connected to the sandbox | loadout, aiwgFrameworks |
agent.disconnected |
Agent connection lost | — |
agent.provisioning |
Agent environment is being set up | loadout, step, progress |
agent.ready |
Agent is ready to accept work | — |
session.start |
Agent has started a task session | sessionId, task |
session.end |
Agent task session completed | sessionId |
hitl.input_required |
Agent is blocked waiting for human input | hitlId, prompt, context, expiresAt |
Events drive agent status transitions in the registry:
connected ──► ready ──► busy (session.start)
▲ │
└──────────┘ (session.end)
provisioning ──► ready (agent.ready)
any ──► disconnected (agent.disconnected)
Agent statuses: starting | provisioning | ready | busy | error | disconnected
Terminal sessions use a separate WebSocket endpoint:
ws://127.0.0.1:7337/ws/pty/<sessionId>?command=aiwg&args=mc,watch&cwd=/path
This bridges the browser's xterm.js terminal to a server-side PTY process. The server buffers up to 64KB of output for replay on reconnection. The client uses exponential backoff (500ms initial, 30s max) for automatic reconnection.
PTY sessions are disabled when --read-only is set.
When an agent sends a hitl.input_required event, the flow is:
- Sandbox pushes the event over the WebSocket connection
- The registry stores a
HitlRequestwithhitlId,prompt,context, andexpiresAt - The dashboard polls
GET /api/hitlevery 2 seconds - The HITL drawer appears with the agent's prompt
- The operator types a response and submits via
POST /api/hitl/:id/respond - The server proxies the response to the sandbox's
POST /api/v1/hitl/:id/respond
Operators can also dismiss requests via POST /api/hitl/:id/dismiss.
agentic-sandbox docs: the full agentic-sandbox documentation is published at docs.aiwg.io/agentic-sandbox (a tenant of the AIWG doc site). Refer there for sandbox setup, the executor contract reference, and runtime details.
agentic-sandbox connects to aiwg serve through two parallel paths: the original sandbox registry (sandbox-level events, dashboard correlation) and the executor contract v1 (mission-level lifecycle, dispatch acceptance). Both stay operational; the executor path is the canonical integration for new work.
-
Start the dashboard:
aiwg serve
-
Configure the sandbox to point at the dashboard:
export AIWG_SERVE_ENDPOINT=http://127.0.0.1:7337 -
Start the sandbox — it auto-registers on both routes:
POST /api/sandboxes/register(existing sandbox path)POST /api/v1/executors/register(executor contract — sandboxeffdb43/ #193)
-
The sandbox appears in the dashboard's Sandbox tab. The executor registration is visible via
GET /api/v1/executors. A2A dispatch readiness is separate: use the v2 admin inventory to find a routableinstance_id, then verify its AgentCard route before sending work.
The executor contract gives aiwg serve a way to dispatch missions to the sandbox and observe mission-level lifecycle events alongside the existing sandbox-level event stream. This is what makes aiwg mc dispatch work end-to-end against a sandbox-hosted executor.
Capabilities advertised at registration:
| Capability | Means |
|---|---|
isolation:vm |
Can execute missions inside KVM/QEMU VMs |
isolation:container |
Can execute missions inside Docker containers |
runtime:claude-code |
Hosts the Claude Code agent runtime |
platform:linux/x64 |
Linux on x86-64 host |
resumable |
Mission state survives mgmt-server restarts (via missions.json) |
hitl |
Supports human-in-the-loop pause/resume round-trip |
Bearer token flow: registration issues a bearer token. The sandbox uses it twice — to authenticate inbound POST /api/v1/sessions/:id/dispatch calls (constant-time check), and to authenticate the outbound /ws/executors/{id} connection (passed as ?token=...). The token never appears in /aiwg/status output.
Dispatch path:
GET /api/v2/admin/instances
GET /agents/{instance_id}/.well-known/agent-card.json
GET /agents/{instance_id}/v1/extendedAgentCard
POST /agents/{instance_id}/v1/messages:send
GET /agents/{instance_id}/v1/tasks/{task_id}
GET /agents/{instance_id}/v1/tasks/{task_id}/subscribe
executor_id identifies the management executor registration. instance_id
identifies the routable A2A sandbox instance. They may be different; dispatch
payloads may pass a2a_instance_id when a concrete sandbox instance is known.
aiwg serve reports the selected a2a_instance_id in dispatch responses.
For v2 A2A dispatch, the returned sandbox task is not fire-and-forget:
aiwg serve follows the task through GET /agents/{instance_id}/v1/tasks/{task_id}
until it reaches a terminal A2A state, then projects that state into the AIWG
mission record. Completed A2A tasks become mission.completed; failed or
rejected tasks become mission.failed; canceled tasks become mission.aborted.
The removed aiwg mc bridge queue tailer no longer sits between Mission
Control queue files and sandbox execution.
Mission state machine:
Assigned ──┬─→ Running ──┬─→ HitlRequired ─→ Running
│ │
│ ├─→ Suspended ─────→ Running (after restart + resync)
│ │
│ └─→ Completed │ Failed │ Aborted (terminal)
│
└─→ Failed (dispatcher couldn't start the session)
Terminal states are excluded from executor.resync.owned_mission_ids.
They are also excluded from GET /api/v1/executors active_mission_count;
that field counts only non-terminal missions currently owned by the executor.
After bringing both processes up:
# Confirm executor registered
curl -s http://127.0.0.1:7337/api/v1/executors | jq
# Dispatch a smoke mission (when #1182 lands; until then drive POST directly)
curl -s -X POST \
-H "Authorization: Bearer $EXEC_TOKEN" \
-H "Content-Type: application/json" \
http://127.0.0.1:8122/api/v1/sessions/$EXECUTOR_ID/dispatch \
-d '{
"mission_id": "smoke-001",
"objective": "echo hello",
"long_running": false,
"executor_filter": { "agent_id": "agent-01" },
"metadata": { "issue": 1180 }
}'
# Tap the executor event stream
websocat "ws://127.0.0.1:7337/ws/executors/$EXECUTOR_ID?token=$EXEC_TOKEN"Expected sequence on a successful dispatch: mission.assigned → mission.started → (work happens) → mission.completed.
| Gap | Impact | Tracked |
|---|---|---|
Exit codes — every session termination emits mission.completed regardless of exit code (PTY return code not plumbed through SessionEnd yet) |
Don't gate AIWG smoke tests on mission.failed from natural exit-1 |
sandbox follow-up |
MissionStore is in-memory |
After a sandbox restart, executor.resync reports an empty owned_mission_ids; AIWG should drop those missions per spec |
sandbox follow-up |
Resumability events (mission.suspended/reconnected/resumed) — emitter constructors exist but aren't called |
Depends on persistence above | sandbox follow-up |
- Wire-shape fixtures:
test/fixtures/sandbox-api/executor-v1/(canonical JSON shapes for every event, request, and response in the contract) - Test strategy:
.aiwg/testing/test-strategy-daemon-serve-sandbox.md§3.2 (contract tier) and §5.1 (fixture inventory) - Sandbox-side spec:
~/dev/agentic-sandbox/docs/aiwg-executor.md(spec_version: 1.0.0) — published at docs.aiwg.io/agentic-sandbox - v1 → v2 sunset awareness (deprecation telemetry): #1259
aiwg serve is designed for local operator use:
- Loopback binding — defaults to
127.0.0.1, not exposed to the network - Per-sandbox tokens — each sandbox receives a unique UUID token at registration; all mutations and WebSocket connections require it
- No global auth — the loopback binding is the primary security boundary
- Same-origin dashboard — the React SPA is served from the same origin as the API, so no CORS configuration is needed
If you need to expose the server on a network interface (--bind 0.0.0.0), place it behind a reverse proxy with authentication.
┌─────────────────────────────────────────────────┐
│ aiwg serve │
│ │
│ Hono HTTP Server (port 7337) │
│ ├── Static files ── compatibility UI (apps/web/dist/) │
│ ├── /api/sandboxes/* ── Sandbox Registry │
│ ├── /api/hitl/* ── HITL Relay │
│ ├── /api/sessions/* ── Session Management │
│ ├── /ws/sandbox/:id ── Event Push (WebSocket) │
│ └── /ws/pty/:id ── Terminal Bridge (WebSocket) │
│ │
│ SandboxRegistry ── agent inventory + events │
│ PTYRegistry ── terminal session lifecycle │
└──────────┬───────────────────────┬───────────────┘
│ │
WebSocket push HTTP proxy
│ │
┌──────────▼───────────────────────▼───────────────┐
│ agentic-sandbox instance │
│ gRPC / HTTP / WebSocket endpoints │
│ Agent provisioning, execution, lifecycle │
└──────────────────────────────────────────────────┘
aiwg serve and aiwg daemon are separate systems:
aiwg serve |
aiwg daemon |
|
|---|---|---|
| Default port | 7337 | 7474 |
| Purpose | Serve API and sandbox registry; Cockpit is the operator console | Background task supervisor |
| Manages | Sandbox instances, agents, HITL | Task queue, scheduled jobs, watches |
| Start command | aiwg serve |
aiwg daemon start |
They can run simultaneously and serve complementary roles.
apps/web is retained as a small compatibility bundle for aiwg serve. The
merged operator console is aiwg cockpit / apps/cockpit/web.
Build the compatibility UI before aiwg serve can serve static files:
cd apps/web
pnpm install
pnpm buildIf the build output (apps/web/dist/) is missing, the server returns a 503 text response instead of the compatibility UI.
For development with hot reload:
cd apps/web
pnpm devThe Vite dev server proxies /api and /ws to http://localhost:7337, so you need aiwg serve running alongside it. New operator-console development should happen under apps/cockpit/web.
"Cannot find module 'hono'" — Install the optional dependencies:
npm install hono @hono/node-serverCompatibility UI shows 503 — Build the web app first:
cd apps/web && pnpm buildSandbox not appearing — Check that:
AIWG_SERVE_ENDPOINTpoints to the correct host and port- The sandbox successfully called
POST /api/sandboxes/register(check sandbox logs) - The WebSocket connection was established (check browser DevTools network tab)
HITL drawer not appearing — Use Cockpit's Approvals tab for the merged operator surface; for legacy serve API debugging, verify the sandbox emitted a valid hitl.input_required event.
The serve seam is covered by a four-tier test strategy documented in .aiwg/testing/test-strategy-daemon-serve-sandbox.md. Running locally:
| Tier | Command | What it tests |
|---|---|---|
| 1 — Unit | npm test |
Module-level logic (mocked transports, no I/O) |
| 2 — Contract | npm run test:conformance |
Executor-contract v1 fixture replay (#1183) |
| 3 — Integration | npm run test:integration:serve |
Spawns real aiwg serve against the in-process fake-sandbox harness (#1174). Drives HTTP API + WS proxy paths end-to-end without a live VM host. |
| 4 — Live UAT | npm run uat:serve-live |
Runs against a real agentic-sandbox at AIWG_SANDBOX_ENDPOINT (default http://127.0.0.1:8122). Skips cleanly when unreachable, so this is safe to run in any environment (#1176). |
Tiers 1–3 run in CI on every push. Tier 4 is operator-driven — see CLAUDE.md release checklist for when to invoke it before a release.
config/vitest.config.js enforces stricter per-directory thresholds for the serve seam since it's the most load-bearing surface in the integration story:
| Path | lines | branches | functions | statements |
|---|---|---|---|---|
src/serve/** |
85% | 80% | 85% | 85% |
tools/daemon/** |
75% | 65% | 75% | 75% |
| global (everything else) | 80% | 70% | 80% | 80% |
npm run test:coverage runs vitest with v8 coverage and fails the build if any threshold drops below the floor. Run it locally before any serve-touching PR to catch regressions before CI.