The wire protocol
The HTTP routes and WebSocket events the office UI is built on.
The orchestrator serves an HTTP API and a single WebSocket. Both are compiled against the same types in packages/core, which is what keeps the protocol honest: the browser and the server cannot disagree about a shape without failing to compile.
That package is worth a sentence on its own. It contains no network code, no filesystem access and no React — nothing but the domain shapes — and both the server and the web app import from it. There is no separate protocol document to fall out of date and no code generator to run. The consequence for a reader of this page is that the type list is the authoritative count: 31 server events and 33 client commands. The project's own README says 24 and 14, and an earlier revision of this page said 27 and 31; both were wrong when written. The union is the vocabulary, and it is worth knowing that the socket implements 26 of the 33 commands and never emits one of the 31 events.
Conventions
- Every HTTP response is JSON, pretty-printed with two-space indentation and sent with
cache-control: no-store. The readability is deliberate — the read API is meant to be usable from a terminal — and the cost is a slightly larger payload than a compact encoding would give. - Reads are cross-origin; writes are not. Every response still carries an
access-control-allow-originheader, because in development the UI is served by Vite from a different origin and talks to the orchestrator across it — but on a state-changing method the server refuses a request whoseOriginis not loopback, with403and an explanation. It used to be a bare*on every route, which meant a page on any website could make your browser write to a local office. A non-browser client sends noOriginand is unaffected, which is the distinction the check relies on and the reason it is not authentication. - Request bodies are capped at 256 KB, and an oversized body is a
413rather than a silent truncation. A body that is not valid JSON is a400withThe request body was not valid JSON. - An unknown path under
/api/gets404withNo such endpoint: <path>, which is a useful answer when you have mistyped a route. - Anything that is a client's mistake — an unknown project, a pipeline a floor cannot staff, a malformed command — answers with a reason rather than
Internal error., because the reason is the only thing that lets a caller fix it. - The WebSocket lives at
/ws. Any other upgrade path has its socket destroyed rather than upgraded.
HTTP routes
The route table below is the complete set — 48 method-and-path operations, resolving from 28 static paths and 8 parameterised matchers, each confirmed in the server's request handler rather than copied from an older page. Note the shape of it: reads are GET and are unauthenticated, and almost every mutation that could be done over the socket has a route as well, so a console whose socket is down is still an operator's console. The two exceptions are run cancellation and answering an approval, both of which are socket-only. One protection is worth stating before the table, because it is easy to mistake for authentication: a state-changing request that arrives with a browser Origin header is refused with a 403 unless that origin is loopback. That closes the drive-by case and nothing else — any process on the machine can still reach the API.
Reads
| Route | Request | Returns |
|---|---|---|
GET /api/health | — | { ok, llmMode, llmModeReason, configStale, configStaleDetail, version, store, uptimeMs, activeRuns, pendingApprovals }. The first thing to call when something looks wrong: it names the resolved mode and the reason for it, and flags that the environment has moved since boot. |
GET /api/state | — | The whole OfficeState — the same object a connecting socket receives in its hello. Useful when you want the full picture without holding a socket open. |
GET /api/runs | — | Every run the engine currently holds, newest first by createdAt. This is the in-memory set, not the archive — for a finished run from a previous process, load it by id. |
GET /api/runs/:id | — | The run, plus turns, artifacts and approvals for it, loaded from the store when the engine no longer holds it. 404 with No run "<id>". when there is no such run. |
GET /api/skills | — | An array of SkillSummary — id, name, description, tags — for every skill the loader read at boot. Deliberately without the bodies, which is also the shape a model is given when it chooses a skill. |
GET /api/tools | — | Every tool an employee could be granted: { name, description, pluginId }. pluginId is null for a built-in tool and names the registering plugin otherwise, which is what makes a plugin's tool grantable from the Org view rather than only by hand-building a role. |
GET /api/workspaces | — | An array of WorkspaceSummary — id, name, path, floor, style, role and skill counts, active runs, spend — for the floor selector. The full organisation only travels for the floor being looked at. |
GET /api/settings | — | The installation OfficeSettings: the eight boot-copied values plus disabledModelIds and modelOverrides. |
GET /api/models | — | The full model catalogue as the router sees it: curated entries, discovered memberships, plugin contributions and any operator override already applied. |
GET /api/providers | — | Per-provider status: whether it counts as configured, how many models it contributes, where its catalogue came from (discovered, degraded or seed) and the hint to show when it is not configured. |
GET /api/plugins | — | The whole PluginSystemState: apiVersion, pluginsRoot, allowInstall, every PluginRecord and every registered marketplace. |
GET /api/plugins/role-templates | — | Role templates contributed by enabled plugins, each tagged with the plugin that contributed it. |
GET /api/plugins/pipelines | — | Pipelines contributed by enabled plugins, likewise tagged. |
GET /api/plugins/sources | — | Just the registered marketplaces, for a UI that wants them without the plugin records. |
GET /api/plugins/catalog?url=… | query: url | The catalogue document a marketplace serves, fetched and shape-checked. This is browsing without installing, and it needs no install gate. 400 when the URL is missing or unreadable. |
GET /api/memory | — | The whole memory ledger — active facts and superseded ones — which is what the console's Ledger tab reads. The state frame carries only the active set plus counts, so this is the only way to see what the office no longer believes. |
GET /api/memory/search?q=… | query: q, plus workspaceId, roleId and limit | Ranked recall, scoped. The scope arguments are not optional decoration: containment is enforced on this one code path, so the query cannot widen what a caller may see. Falls back to an unranked scan, and says so, when the full-text index is unavailable. |
GET /api/plugins/:id/panels/:panelId | — | A contributed panel, resolved server-side: the host asks the plugin's endpoint and returns the validated widgets. 200 when it read, 404 when it could not — "this panel could not be read" is a panel state, not a client error. The plugin's URL never reaches the browser. |
Actions
| Route | Body | Returns |
|---|---|---|
POST /api/submit | { brief, pipelineId?, budgetUsd?, workspaceId? } — brief must be a non-empty string | 202 with the created Run. A brief the office refuses — an unknown project, an unstaffable pipeline — is a 400 with the reason, and A non-empty "brief" string is required. when the brief is missing. submittedBy is recorded as http, so a run started by a script is distinguishable from one started in the console. |
POST /api/chat | { employeeId, text } | The resulting DirectMessage[]. A conversation outside any pipeline: no run, no stage, no turn. |
POST /api/plan | { employeeId, text, history?, workspaceId? } | The planning reply. Same shape, same validation and same HISTORY_LIMIT of 40 turns with a 4000-character cap per turn as the socket's plan command, so the two paths cannot drift into answering differently. History roles other than user and assistant are dropped before anything reaches a prompt. |
POST /api/workspaces | { name, description?, color?, folder?, path?, skillIds? } | 201 with the new WorkspaceSummary, and a log line in the feed. folder is a directory name resolved under the workspaces root; path is an absolute override and is refused unless external workspaces are enabled. 400 with A non-empty "name" is required. otherwise. |
PUT /api/workspaces/:id | Any subset of { skillIds, budget, name, description, color, style } | The updated summary. Applied in order, so a bad value anywhere leaves the organisation as it was rather than half-updated. An explicit "style": null resets the floor to the default preset; an absent style leaves the look alone. With none of the six fields present: Nothing to update: send skillIds, budget, name, description, color or style. |
DELETE /api/workspaces/:id | — | { ok: true }. Closes a floor. Its directory and its runs are left alone, which is deliberate: closing an organisation is not a request to delete a person's files. |
POST /api/settings or PUT /api/settings | A partial OfficeSettings | The settings as they now stand. Both verbs are accepted so a console and a script can each use the one they mean. |
POST /api/models/discover | { providerId?, force? } — force defaults to true | { reports, providers, models }: the per-provider outcome, the refreshed statuses and the resulting catalogue. A provider that could not be reached is a 200 carrying the reason, because the request succeeded and "I could not ask" is the answer. In mock mode it returns reports: [] and the note mock mode keeps the curated catalog; discovery is skipped. An unknown provider id is a 404. |
POST /api/models/benchmarks | — | Refreshes pooled quality on demand. { ok, coverage, models, … }, or { ok: false, error: "benchmark quality is switched off (DEV3D_BENCHMARKS=false)" } with a 200 — the switch being off is a state, not a failure. |
POST /api/models/health | { limit? }, clamped to 1..50 and defaulting to 20 | { ok, considered, records, status, … }. Bounded on purpose: this is one request per model, so refreshing the whole 445-model catalogue would be 445 requests for a signal the router only consults for models actually in play. The records are returned because uptime is a per-model lookup a console cannot read out of office.models. |
POST /api/memory | { scope, kind, text, tags?, source?, workspaceId?, roleId? } | 201 with the new fact. Text is capped at 500 characters, tags at 12 and source at 200. A correction sends a supersedes reference and writes a replacement rather than editing the original. |
POST /api/memory/:id/retract | { reason? } | { ok: true }. Marks a fact no longer true and keeps it on file. There is no delete: the ledger's whole value is that the earlier belief stays answerable. |
POST /api/memory/embed | { limit? } | Embeds facts that have no vector yet, in batches. Only meaningful when semantic recall is switched on; it exists because switching vectors on does not embed the existing store in one go. |
POST /api/mcp/refresh | — | Re-reads the MCP configuration and reconnects: removed and disabled servers are disconnected and their tools withdrawn, failed ones are retried, and the rest are left alone. This is the only way to retry a failed server, because there is deliberately no automatic reconnect. Answers with the new MCP state. |
POST /api/vendors/refresh | — | Re-probes the configured agent harnesses for their version. A vendor is probed once at boot and never polled again, so this is the operator's hand on that check. |
POST /api/plugins/refresh | — | The new PluginSystemState, after re-scanning both plugin directories. This is how a directory added by hand is picked up. |
POST /api/plugins/updates | — | { …result, state }. Asks every registered marketplace what it offers. 200 even when one was unreachable: the others were still checked, and the failure is reported per source in the state. |
POST /api/plugins/install | { catalogUrl, pluginId, upgrade? } | 201 with the new PluginRecord. 400 with "catalogUrl" and "pluginId" strings are required. when either is missing, and the install-gate refusal when DEV3D_ALLOW_PLUGIN_INSTALL is not true. |
POST /api/plugins/sources | { label?, url } | 201 with the new PluginSourceRecord. A URL that does not start with http:// or https:// is refused, and so is one that is already registered. An empty label becomes the URL's hostname. |
DELETE /api/plugins/sources/:id | — | { ok: true }. Unregisters a marketplace. Installed plugins stay installed. |
POST /api/plugins/:id/enable | { enabled }, a real boolean | The plugin's updated record. An "enabled" boolean is required. otherwise. Disabling withdraws the plugin's contributions and calls deactivate(). |
PUT /api/plugins/:id/settings | { settings }, an object | The plugin's updated record, with stored values coerced against the manifest schema. A "settings" object is required. otherwise. |
DELETE /api/plugins/:id | — | { ok: true }. Installed plugins only: a bundled plugin is refused with the reason. |
There is no POST /api/runs/:id/cancel. It is a natural thing to try and it is not there — cancelling a run is the WebSocket cancel command and nothing else. The URL falls through to the catch-all and answers 404 { "error": "No such endpoint: /api/runs/run_ab12cd34/cancel" }. If you have seen it written down anywhere, that page is wrong.
The WebSocket
One socket carries everything: the server pushes ServerEvents, the client sends ClientCommands. There is no request/response pairing to track, with two deliberate exceptions — plan is answered only to the socket that asked, and a command that fails produces an error event on the same socket.
On connect
The server sends one hello carrying the entire OfficeState: settings, the active floor, every floor summary, the active organisation's company, departments, roles and employees, its skills, budget and style, the floor's layout and capacity, pipelines, runs, the full model catalogue, provider statuses, model-signal coverage, the resolved LLM mode and the reason for it, any configuration drift, the routing posture, the plugin state and the version. A console therefore never has to fetch anything to become correct; it has to render one object.
That object is what resync returns. ping used to answer with it too, and no longer does: it answers with a pong carrying nothing but a timestamp. The change is not cosmetic. On a live office the whole state is hundreds of kilobytes and the shipped console pings every 25 seconds, so a keepalive that re-sent the office was worth roughly a gigabyte a day per open tab to prove a socket was still there. Use ping for liveness and resync when you actually need a fresh state.
The server also broadcasts office.updated with a fresh whole state at the moments the roster, the floor list or the run list actually changes: a new run, a run reaching a terminal status, a settings change, an organisation change. It does not poll on a timer. Anything else that changes what the aggregate state looks like — a plugin being toggled, a workspace being edited, a discovery run finishing, a benchmark refresh — broadcasts one explicitly from the route handler that caused it.
Whole-state events rather than deltas
office.updated, hello, org.updated, settings.updated and plugins.updated all carry a complete object rather than a patch. The plugin case is the sharpest illustration: plugins.updated carries the whole PluginSystemState — every record, every marketplace, the install gate and the API version — not the one plugin that changed. The reason is failure tolerance. A console that missed an event, or received them out of order, or connected halfway through a sequence is corrected by the next one rather than left with a delta applied to a state it never had. It costs bytes on a local socket and buys the absence of a whole class of bug.
All 31 server events
| Event | Payload | Sent when |
|---|---|---|
hello | { state: OfficeState, at } | On connect, on resync, and when a marketplace update check completes for the caller. The models array inside it is a projection: each model's quality opinions are trimmed to their source names, because the full set was 87 kB of a 299 kB frame. The complete specs are on GET /api/models. |
pong | { at } | The answer to the ping keepalive. It used to be answered with a whole office.updated, which on a live office is hundreds of kilobytes every 25 seconds — roughly a gigabyte a day per open console — to prove the socket was still there. |
office.updated | { state: OfficeState, at } | The aggregate state changed: a run appeared or ended, settings changed, an organisation changed, a floor was added or removed, a plugin was toggled, discovery finished. |
org.updated | { workspaceId, org: OrgChart, at } | One organisation's chart changed — hires, fires, grants, policies, seats, rooms. Carries which organisation, so a console showing a different floor is not confused by it. |
settings.updated | { settings: OfficeSettings, at } | Installation-wide settings changed. |
plugins.updated | { state: PluginSystemState, at } | A plugin was enabled, disabled, configured, installed or removed, or the directories were rescanned. |
run.created | { run: Run, at } | A brief was accepted, from the socket or from POST /api/submit. |
run.updated | { run: Run, at } | The run's status, spend or outcome changed. A terminal status also triggers a fresh office.updated. |
stage.started | { runId, stage: StageRun, at } | A stage began executing. |
stage.finished | { runId, stage: StageRun, at } | The stage settled — done, failed or skipped. |
turn.started | { turn: TurnRecord, at } | One employee's turn began. The record already carries the route decision it was given. |
turn.delta | { runId, turnId, text, at } | Streamed output text, as it arrives from the model. |
turn.reasoning | { runId, turnId, text, at } | Streamed reasoning text, when the model exposes it separately from its answer. |
turn.finished | { turn: TurnRecord, at } | The turn completed, including its tool calls, usage and final route. |
employee.updated | { employee: EmployeeState, at } | An employee's status, spend, model or last route changed — which is what moves them around the 3D office. |
employee.moved | { employeeId, fromSeatId, toSeatId, toRoomId, at } | Someone changed desk, including to or from null. |
speech | { runId, stageId, fromEmployeeId, toEmployeeIds, text, kind, at } | An employee addressed someone. kind is one of debate, report, question, answer, handoff — which is what decides how it renders. |
tool.result | { runId, turnId, call, at } | One tool call settled. The call is the same record the turn's transcript holds, including its status. |
artifact.created | { artifact: Artifact, at } | A stage produced an artifact — a document, a diff, a report. |
approval.requested | { approval: Approval, at } | A tool wants permission, or a run crossed the soft-spend threshold. The approval's kind and turnId tell the two apart: a spend approval has turnId: null. |
approval.decided | { approval: Approval, at } | An approval was answered, or timed out and therefore counted as refused. |
direct.message | { employeeId, messages: DirectMessage[], at } | A chat turn to one employee finished. Broadcast rather than addressed, because it is part of the floor's history. |
plan.reply | { employeeId, requestId, text, route, at } | One planning turn was answered. Sent only to the socket that asked, because a plan is a private draft. requestId echoes the command's, when it sent one. |
budget.updated | { runId, limitUsd, spentUsd, at } | A run's spend moved, with the ceiling it is being measured against. |
routing.decision | { runId, turnId, route: RouteDecision, at } | A model was chosen for a turn. The decision carries the considered candidates, their scores and the clause list explaining the choice. |
memory.created | { fact: MemoryFact, at } | A fact was written down. A correction emits this for the replacement as well as the retraction below, so both halves of a supersession arrive together. |
memory.retracted | { id, at } | A fact stopped being believed. The record stays: retraction marks it no longer true rather than deleting it, because the whole point of the ledger is that the earlier belief remains answerable. |
memory.updated | — | Declared and never emitted. It is in the type union and no code path sends it; memory changes ride memory.created and memory.retracted. It is listed here because the union is the protocol's vocabulary, and a reader comparing the two should know which members are real. |
usage | { employeeId, lifetime: EmployeeUsage, at } | An employee's lifetime token and cost totals changed. A turn whose token counts were estimated rather than reported by the provider is flagged as such, so an estimate is never rendered as a bill. |
log | { level, scope, message, at } | A server log line worth surfacing in the console feed. level is debug, info, warn or error; scope is the same bracket tag the terminal log uses. |
error | { message, runId?, at } | Something the caller should know: a malformed command, an unknown command, a run that is not running, an approval that is no longer pending, a failed command. runId is present when the error belongs to a run. |
All 33 client commands
| Command | Payload | Effect |
|---|---|---|
submit | { brief, pipelineId?, budgetUsd?, workspaceId? } | Commission a run. budgetUsd here overrides the floor's default ceiling, and it works — unlike DEV3D_RUN_BUDGET_USD. |
cancel | { runId } | Stop a run; in-flight turns are cancelled. Run "<id>" is not running. when it is already over. |
chat | { employeeId, text, workspaceId? } | Talk to one employee outside any pipeline. The reply is broadcast as direct.message. |
plan | { employeeId, text, history?, workspaceId?, requestId? } | Shape a brief before commissioning it. Stateless on the server by design: nothing is commissioned until it is submitted, so there is nothing to persist. Answered privately with plan.reply. |
approve | { approvalId, approved } | Answer a pending approval. Approval "<id>" is no longer pending. when it has already been decided or has timed out. |
selectWorkspace | { workspaceId } | Switch which organisation the console shows. Followed by a broadcast office.updated, so every client moves floor together rather than one tab drifting out of step. |
setModelPolicy | { roleId, policy, workspaceId? } | Re-tune one role's model policy. |
setRoleGrants | { roleId, allowedTools?, skillIds?, workspaceId? } | Change what one employee may hold. Both lists are replaced rather than merged, and both are filtered by the server against what actually exists. This is the only way to grant a tool a plugin registered. |
setSeat | { employeeId, seatId, roomId?, workspaceId? } | Move an employee to a different desk. seatId accepts null for unseated. |
hire | { role, workspaceId? } | Add a role to an organisation. |
fire | { roleId, workspaceId? } | Remove a role from an organisation. |
setRoutingPosture | { posture, workspaceId? } | Change the floor's routing posture. This is the per-floor override the engine prefers over the installation default. |
addRoom | { workspaceId? } | Build one more room on a floor. A floor also grows by itself when its roster outgrows its desks; this is the operator's hand on the same machinery. |
removeRoom | { workspaceId? } | Take the newest module back out. Never goes below what the roster needs. |
setWorkspaceSkills | { skillIds, workspaceId? } | Set which skills an organisation's employees may draw on. |
setWorkspaceBudget | { budget, workspaceId? } | Set an organisation's money, including defaultRunUsd — the value that is the real per-run ceiling. |
setWorkspaceDetails | { name?, description?, color?, workspaceId? } | Rename, recolour or describe an organisation without touching its files. |
setWorkspaceStyle | { style, workspaceId? } | Restyle a floor. A whole style rather than a patch, because the editor renders the resolved palette and always sends a complete answer; null returns the floor to the default preset. |
createWorkspace | { name, description?, color?, folder?, path? } | Open a new organisation. folder is the safe path and what the UI offers first; path is the absolute override, refused unless external workspaces are enabled. |
removeWorkspace | { workspaceId } | Close an organisation. Its directory and its runs are left alone. |
updateSettings | { patch } | Change installation-wide settings. |
setPluginEnabled | { pluginId, enabled } | Turn an installed plugin on or off. Disabling unloads what it contributed. |
configurePlugin | { pluginId, settings } | Change a plugin's settings, merged over the manifest defaults and coerced against the schema. |
refreshPlugins | — | Re-scan the plugin directories, picking up anything added by hand. |
installPlugin | { catalogUrl, pluginId } | Install from a marketplace catalogue entry. Subject to the same install gate as the HTTP route. |
removePlugin | { pluginId } | Remove an installed plugin's directory. |
addPluginSource | { label, url } | Register a marketplace to browse. |
removePluginSource | { sourceId } | Unregister a marketplace. |
rememberFact | { scope, kind, text, tags?, source?, workspaceId?, roleId? } | Write a fact down. A correction sends the same command with a supersedes reference, which writes the replacement and marks the original no longer true: it never overwrites, and it cannot widen the fact's scope. |
retractFact | { id, reason? } | Stop believing a fact. The record stays on file, so what the office used to believe remains answerable. |
loadRun | { runId } | Open a run's full transcript. The persisted events are replayed to that socket only, which rebuilds the transcript exactly as it was streamed the first time and needs no extra protocol surface. An unreadable row is skipped rather than aborting the replay. Streaming deltas are no longer persisted, so a replay reconstructs the turn's final text rather than re-streaming it. |
resync | — | Re-request the whole office state. The command to send after a reconnect. |
ping | — | A keepalive, answered with pong rather than with the office state. The shipped console sends it every 25 seconds so an idle socket is not silently dropped by a proxy. |
Seven of those commands are declared and not implemented on the socket. The type union is authoritative for the protocol's vocabulary, but the server's command switch handles 26 of the 33 types. setPluginEnabled, configurePlugin, refreshPlugins, installPlugin, removePlugin, addPluginSource and removePluginSource are declared in ClientCommand and fall through to the default branch, which answers Unknown command "setPluginEnabled". Every one of them has a working HTTP route, and that is what the console uses. If you are scripting the plugin surface, use HTTP.
Driving a run without the browser
The two transports split cleanly: HTTP is for asking and for one-shot actions, the socket is for watching. A script that needs to see a run progress needs both.
1. Check what you are talking to
curl -s http://127.0.0.1:8787/api/health
The response tells you the mode and why, whether the environment has drifted since boot, and how many approvals are pending. If llmMode is mock, the whole run below will complete without a single outbound call — which is a perfectly good way to exercise a script before spending anything.
2. Find the floor you mean
curl -s http://127.0.0.1:8787/api/workspaces \
| node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{\n for (const w of JSON.parse(s)) console.log(w.id, '\\t', w.name, '\\t', w.path, '\\t', w.spentUsd, '/', w.budgetTotalUsd ?? '-');\n });"
Each row carries id, path, roleCount, skillCount, activeRuns and spentUsd. A WorkspaceSummary is a summary on purpose: the full organisation only travels for the floor being looked at, which is why GET /api/state is the endpoint that carries it.
3. Submit the brief
curl -s -X POST http://127.0.0.1:8787/api/submit \
-H 'content-type: application/json' \
-d '{"brief":"Fix the null dereference in the session lookup and add a regression test","workspaceId":"default","budgetUsd":2.5}'
The reply is 202 with the created Run, including its id, pipelineId, workspacePath and status. Two things about the body are worth noting. pipelineId is optional and the office picks a pipeline that fits the brief when it is absent, so omitting it is a legitimate choice rather than an omission. budgetUsd is a hard override of the floor's defaultRunUsd and it does take effect — the engine reads it as the ceiling and fails the run when spend reaches it before a stage.
4. Watch it
Everything the UI shows arrives on ws://127.0.0.1:8787/ws as JSON frames, one object per message. websocat is the shortest path to seeing them:
websocat ws://127.0.0.1:8787/ws
You will immediately receive one hello frame holding the whole office, then one frame per event after that. Frames are not newline-delimited — each WebSocket message is one JSON object — so grep, jq and friends will not line up on them the way they do on a log file. The reliable reader is a small script that parses one frame at a time, which is exactly what the shipped console does:
const ws = new WebSocket('ws://127.0.0.1:8787/ws');
ws.addEventListener('open', () => {
ws.send(JSON.stringify({ type: 'ping' }));
});
ws.addEventListener('message', (event) => {
const e = JSON.parse(event.data);
if (e.type === 'hello') {
console.log('office:', e.state.workspaces.length, 'floor(s),', e.state.models.length, 'models,', e.state.llmMode);
ws.send(JSON.stringify({
type: 'submit',
brief: 'Fix the null dereference in the session lookup and add a regression test',
budgetUsd: 2.5,
}));
return;
}
if (e.type === 'run.created') {
console.log('run', e.run.id, 'on', e.run.pipelineId, 'in', e.run.workspacePath);
return;
}
if (e.type === 'routing.decision') {
console.log(' route →', e.route.modelId, '(', e.route.tier, ') because', e.route.reason);
return;
}
if (e.type === 'tool.result') {
console.log(' tool', e.call.name, e.call.status);
return;
}
if (e.type === 'approval.requested') {
console.log(' approval', e.approval.id, e.approval.kind, e.approval.summary);
return;
}
if (e.type === 'log') {
console.log(' [' + e.level + '] ' + e.scope + ': ' + e.message);
return;
}
if (e.type === 'run.updated') {
console.log(' run', e.run.status, '$' + e.run.budget.spentUsd.toFixed(4), 'of', e.run.budget.limitUsd);
if (e.run.status === 'done' || e.run.status === 'failed' || e.run.status === 'cancelled') {
console.log('outcome:', e.run.outcome ?? '(none)');
ws.close();
}
}
});
Four details in that script are the ones that matter when you write your own. A run's status is only final at done, failed or cancelled; waiting for run.updated alone is not enough, because the event fires many times. Approvals require an answer, and the engine will refuse on your behalf after DEV3D_APPROVAL_TIMEOUT_MS — so a script that ignores approval.requested gets a refused shell command rather than a hang. routing.decision carries the reason clause list, which is the single most useful field for understanding a run after the fact. And the opening ping is a liveness check whose answer is a pong, not a state refresh: the frame the script acts on is the hello, and if you need to be certain you are current later, send resync rather than ping.
5. Answer an approval, or cancel
# approve, from the socket
websocat -1 ws://127.0.0.1:8787/ws \
<<< '{"type":"approve","approvalId":"apr_ab12cd34","approved":true}'
# and the same socket stops the whole run
websocat -1 ws://127.0.0.1:8787/ws \
<<< '{"type":"cancel","runId":"run_ab12cd34"}'
Note the asymmetry, because it catches people out: both of those are socket commands, and there is no HTTP route for either. Cancellation is the cancel command and nothing else — a POST to /api/runs/run_ab12cd34/cancel does not reach a handler. It falls through to the catch-all and answers 404 with No such endpoint: /api/runs/run_ab12cd34/cancel, which is at least a legible refusal rather than a silent no-op. The /api/runs/:id shape with a trailing verb is not part of this API; if you want to act on a run, the run-scoped verbs live on the socket.
websocat -1 closes after one message, which is what makes it usable in a shell pipeline — but it also means it never sees the approval.decided or run.updated frames that follow. Use it to send one command and a long-lived listener to watch the consequences.
6. Read the transcript afterwards
curl -s http://127.0.0.1:8787/api/runs/run_ab12cd34 \
| node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const r=JSON.parse(s);\n console.log(r.status, r.pipelineId, '$'+r.budget.spentUsd.toFixed(4), 'of', r.budget.limitUsd);\n for (const t of r.turns) console.log(' ', t.stageId, t.employeeId, t.route?.modelId, '$'+t.usage.costUsd.toFixed(5), t.status);\n });"
This works whether or not the run is still in memory, because the route falls back to the store. It is the reason loadRun on the socket replays persisted events rather than reconstructing anything: the events themselves are the record.
Why the shared package matters
Both sides import ClientCommand and ServerEvent from packages/core, which touches no network, no filesystem and no React. Three consequences follow, and they are the reason the package exists rather than a types.ts in each app.
- A shape change is a compile error, not a runtime surprise. Rename a field on
Runand the server's emitter, the console's reducer and the store's serializer all stop compiling together. - There is no second description of the protocol to drift. The counts on this page — 31 server events and 33 client commands — come from the union itself. Everything else, including the project's README, is a copy that can be wrong, and was. Two caveats belong with those numbers: the socket implements 26 of the 33 commands, and one of the 31 events is never emitted.
- The event union is exhaustively switchable. Because
ServerEventis a discriminated union ontype, a consumer that adds a handler for every variant and forgets one gets a type error rather than a silently ignored event — which is how a console ends up quietly not rendering something.
The dependency-free part matters as much as the shared part. A contracts package that pulled in a validation library would drag that library into the browser bundle and into the server's cold path, and would give both sides a reason to reach for runtime checks instead of types. The package's own description says it plainly: nothing in it touches the network, the filesystem, or React.
What to check when a call does not work
| Symptom | Likely cause |
|---|---|
Upgrade Required or an immediate disconnect | You connected to / or another path instead of /ws. Any other upgrade path has its socket destroyed. |
The first frame is not hello | Something else is on that port, or you are connected to a proxy that is not forwarding to the orchestrator. |
Malformed command: not valid JSON. | The frame was not a JSON object. WebSocket frames are not newline-delimited, and some CLI tools add a prompt or a trailing newline. |
Unknown command "x". | The command is not implemented on the socket, or you have spelled it wrong. See the seven plugin commands above. |
A submit produces nothing at all | The command was accepted but the run was refused and the reason arrived as an error frame. Print every frame while debugging rather than filtering for the ones you expect. |
| An approval never appears to resolve | It resolved as refused after the timeout, which emits approval.decided rather than approval.requested. A timeout is a decision. |
GET /api/runs/:id returns 404 | The id is wrong, or the run was submitted to a different installation — runs are per-database, and a script pointing at a stale port reaches a different office. |
| The socket is fine and the state is stale | You are connected, but nothing has changed, so nothing has been broadcast. Send resync to pull a fresh whole state — ping only proves the socket is alive and answers with a pong. |
| A panel or a plugin action fails over the socket | Use the HTTP route. Plugin mutation is the part of the protocol the socket does not implement. |
404 No such endpoint: /api/runs/…/cancel | You are trying to cancel over HTTP and there is no such route. Cancellation is the socket's cancel command; a trailing verb on /api/runs/:id is not part of this API. |
Where to go next
Linked from
Did this page answer your question?