switchboard-orchestration
Skill215 starsChanged 2 months agoMITView on GitHub
The complete HTTP contract for driving Switchboard from outside the webview — for a fleet coding/review agent in a Mission Control worktree, an external Mission Control driving the whole board, or a team member reading the local board. LocalApiServer is the sole writer.
In this repository19 other files it gives its agents.
- AGENTS.mdAGENTS.md
- CLAUDE.mdCLAUDE.md
- .github/copilot-instructions.mdCopilot instructions
- improve-featureSkill
- improve-planSkill
- external-team-leadSkill
- improve-featureSkill
- kanban-operationsSkill
- manage-featuresSkill
- query-kanbanSkill
- worktree-cleanupSkill
- kanban-operationsSkill
- manage-featuresSkill
- query-kanbanSkill
- switchboard-cloudSkill
- switchboard-memoSkill
- switchboard-remoteSkill
- switchboardSkill
- worktree-cleanupSkill
The file
We split this skill into the rules it gives an agent
12 rules. About 0 are common, 5 are distinctive, and 7 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈5
Distinctive · estimate
This author's own choices. Worth a look.
7
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 36
Verify Switchboard is up before anything else:
- Very specific: line 41
If switchboard api fails with server not running, Switchboard is not running — tell the user to open the workspace in VS Code with the …
- Very specific: line 45
Auth: All endpoints sit behind the localhost boundary.
- Very specific: line 227
Read verbs (get* / fetch* / load*) over the generic rail return only {success:true} — their data arrives on the WS hub, which an HTTP …
- Distinctive: line 256
When you are a head agent driving a coder terminal (dispatch a subtask, get called back, review the diff, resend a fix) — not running an …
- Distinctive: line 309
Never clear your own terminal.
- Very specific: line 310
Never use ptyClearAllTerminals (it clears every active terminal, you included).
- Distinctive: line 395
Read board state via these endpoints.
- Distinctive: line 401
When an operator asks you to monitor a fleet until completion, three concrete signals define "done".
- Distinctive: line 455
If the API server is down you can still communicate via the filesystem (Mission Control reads these):
- Very specific: line 472
Every row on this channel is host-written.
- Very specific: line 481
When the operator machine is reachable over Tailscale, the board and terminals are addressable at http://100.x.y.z:7777 (the Tailscale IP, …
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Skill: Switchboard Orchestration HTTP Surface
This is the complete HTTP contract for driving Switchboard from outside the VS Code webview — whether you are a fleet coding/review agent working inside a Mission Control worktree, an external Mission Control (Cursor / Zed / Claude Code / Antigravity) driving the whole board, or a team member (a discoverable-skill agent) reading the local board.
Switchboard's LocalApiServer runs inside the VS Code extension and is the sole writer of
kanban.db. You never touch the DB directly — you call these endpoints. The board is the source
of truth; the UI is just one view of it.
Endpoint inventory. The hand-written list below is a subset for the calls agents most often make. The authoritative, generated inventory is
GET /catalog— it lists every verb, endpoint, and prefix the server actually serves (drift-checked in CI viacatalog:check), so it can never lag behind the server the way a hand-maintained list can. When you need a call this document does not cover, hitGET /catalogfirst; the payload contract for any call below still lives here, since the catalog carries only{path, method, prefix}and not request shapes.
Behavior vs. invocation. This skill is the invocation authority (endpoints, verbs, payload fields). For behavior contracts — how the system behaves (cards move on coding start, completion = plan-file mtime advance, plan files are write-once-at-the-end, subtask column exclusion) — consult the
switchboard-contractsprotocol (switchboard api GET /protocol/switchboard-contracts). Never consult that skill for invocation; never consult this skill for behavior conventions.
1. Bootstrap
Verify Switchboard is up before anything else:
switchboard api GET /health # -> { status: 'ok', port, roots: [...] }If switchboard api fails with server not running, Switchboard is not running —
tell the user to open the workspace in VS Code with the Switchboard extension active (or run the standalone host). Do not fall
back to editing kanban.db directly.
Auth. All endpoints sit behind the localhost boundary. If a token is set in VS Code
(Switchboard: Api Token), the CLI handles auth automatically.
Multi-root. /health returns roots. DB-backed endpoints accept an optional
?workspaceRoot=<root> (GET) or "workspaceRoot" body field (POST/PUT/DELETE); omit it to use the
primary workspace.
Response envelope.
- Read endpoints return
{ "success": true, "data": <payload> }. - Mutation endpoints return
{ "success": true, ...fields }(nodatawrapper). - Errors return
{ "error": "<message>" }with an HTTP status:400bad input,401unauthorized,404not found,409conflict,503store/extension not ready,500handler error. - A
503from a read endpoint also carriescodeandtier:{ "error": "...", "code": "STORE_UNAVAILABLE", "tier": "board" | "archive" }.
Three read outcomes — branch on all three. A board read has exactly three answers, and two of them used to be the same value:
| Outcome | Shape | Meaning |
| Rows | 200 {success:true, data:...} | The store answered. An empty data: [] genuinely means nothing matches. |
| No such record | 404 {error} | The card does not exist — in the board window or the archive. Both were searched. |
| Store unavailable | 503 {error, code:"STORE_UNAVAILABLE", tier} | The store did not answer. You know nothing about the board's contents. |
STORE_UNAVAILABLE is not an empty board. An orchestrator that reads it as one makes
confident decisions about a board it cannot see — it will dispatch nothing, report a fleet
as idle, or mark work missing. On STORE_UNAVAILABLE: retry once after a few seconds; if
it repeats, stop and report, naming the tier that failed. Never poll it in a loop, and
never fold it into "no cards found".
2. Read endpoints (GET)
| Endpoint | Returns | |||
GET /health | { status, port, roots } — liveness + workspace roots | |||
GET /kanban/board | Full board: every active plan record for the workspace | |||
GET /kanban/plans?column=<col> | Plans filtered to one column | |||
GET /kanban/plans?featureId=<id> | Subtasks of a feature | |||
GET /kanban/plan?planId=<id> | One plan record, its full file content (.data.content), and which store answered (.data.source: 'board' | 'archive') | |||
GET /kanban/columns | { builtIn: [...defs], custom: [{id,label,labelSource,enabled,displayModeOf?,legacyAliasOf?}], displayOnly: [{label,aliasOf}] } — every column includes enabled (boolean) and enabledSource (`'config' | 'default' | 'structural' | 'unknown'). displayModeOf/legacyAliasOf mark a column that is NOT an independent peer (BACKLOGis a view ofCREATED; CODEDis a legacy alias ofLEAD CODED). Filter destinations to enabled !== false`. |
GET /kanban/features | All features (isFeature rows) | |||
GET /kanban/dependencies[?planId=<id>] | The durable dependency graph. With planId: that plan's dependencies (its depends_on_plan_id list), its mapFingerprint, and its analysisFileSet. Without: every edge in the workspace. Read-only — a controller reads this to arrange missions | |||
GET /kanban/sendable?column=PLAN%20REVIEWED | The batch that can go now: { sendablePlanIds, stalePlanIds }. Dependency-ready plans in the column, greedily selected so no two share a file (computed by the same resolver the board filter uses). Read-only — no mission, no staging, no card move. stalePlanIds names cards whose plan file changed since analysis | |||
GET /worktree/list | All worktree rows (path, branch, subtask_plan_id, feature_id, tier, status, base_branch) | |||
GET /mission-control/session-log | Mission Control's session file — .switchboard/mission-control/session.md when it exists, falling back to the legacy .switchboard/mission-control/session-log.md (markdown string, '' when neither exists) |
switchboard api GET /kanban/board
switchboard api GET "/kanban/plans?column=PLAN%20REVIEWED"
switchboard api GET "/kanban/plan?planId=a1b2c3d4" # includes .data.content (the .md file)
switchboard api GET /kanban/features
switchboard api GET /worktree/list⚠
planIdis NOT a valid query filter onGET /kanban/plans. The handler (_handleGetPlans, LocalApiServer.ts:6483) reads onlycolumnandfeatureIdfrom the query string — aplanIdparam is silently ignored and the full workspace array is returned. An agent consumingres.data[0]then inspects whichever card happens to be first in the DB (often from a different column), generating false reports. For a single plan, useGET /kanban/plan?planId=<id>(returns one plan plus its file content at.data.content). Client-side filtering (res.data.find(p => p.planId === id)) works but fetches the whole board — prefer the dedicated endpoint.Column filter requires the storage column id, not the board label.
GET /kanban/plans?column=Plannedreturns an empty array — the stored id isPLAN REVIEWED, URL-encoded asPLAN%20REVIEWED. See the column-translation table inquery-kanban/SKILL.mdor callGET /kanban/columnsfor the live{id, label}mapping.
Record lookups span the archive; collection reads stay windowed. The board keeps a window of recent work; completed cards that age past it move to a separate archive store. The two kinds of read treat that boundary differently, on purpose:
GET /kanban/plan?planId=spans both stores and labels the result with.data.source('board'or'archive'). A card is returned exactly once even while the archive sweep is moving it. So a404here is a real absence in both stores — never "it got old". The storage window is a fact about where a card is kept, not a fact about the card.GET /kanban/board,GET /kanban/plansandGET /kanban/featuresare windowed and deliberately exclude dormant cards — that is the human board's view, and spanning them would flood it. To ask about a specific old card, use the record lookup.
.data.source is where the record was found; a GET does not promote a dormant card
back to the board.
Plan records include: planId, sessionId, topic, planFile, kanbanColumn, status,
complexity, tags, project, isFeature, featureId, worktreeId, worktreeStatus,
dispatchedAt (null = not currently working), recommendedRole.
recommendedRole (lead | coder | intern) is the seat the board would route this plan
to — resolved through the operator's kanban.routingMapConfig and the pair-mode intern bypass,
the same rule POST /kanban/dispatch uses for "auto". Dispatch a subtask to a seat of that
role on your team; if your team has no such seat, dispatch to a coder and say why. Absent when
the plan's complexity is unknown — treat absence as coder. Do not re-derive it by reading
the plan file's Recommendation: line: nothing in the system parses that line, and a remapped
board or an active pair mode would make it wrong.
3. Plan lifecycle (POST / PUT / DELETE)
| Endpoint | Body | Purpose |
POST /kanban/plans | { title, slug?, complexity?, tags?, project?, description?, body?, workspaceRoot? } | Create a plan (writes .switchboard/plans/<slug>.md, imports it, returns assigned planId). workspaceRoot must match a server root from GET /health (roots), else 400. |
DELETE /kanban/plans?planId=<id>[&deleteFile=true] | — | Delete the DB row; deleteFile=true also unlinks the .md |
PUT /kanban/plans/project | { planId, project, workspaceRoot? } | Set a plan's project |
PUT /kanban/plans/complexity | { planId, complexity, workspaceRoot? } | Set a plan's complexity ("1"–"10" or "Unknown") |
PUT /kanban/plans/priority | { planId, starred, workspaceRoot? } | Set a plan's priority star (starred: true/false/1/0/"true"/"false"). Starred cards sort first in every consumer. Idempotent. Accepts sessionId as an alias for planId. Returns { success, planId, starred }. |
POST /kanban/plans/import | { workspaceRoot? } | Rescan .switchboard/plans/*.md and upsert. workspaceRoot must match a server root from GET /health (roots), else 400. |
# Create — returns { success:true, planId, planFile, slug }
switchboard api POST /kanban/plans '{
"title": "Add rate limiting to the API",
"complexity": "5",
"tags": "backend, api",
"body": "Add token-bucket rate limiting to the public endpoints."
}'
switchboard api PUT /kanban/plans/project '{"planId":"a1b2c3d4","project":"Platform"}'
switchboard api PUT /kanban/plans/priority '{"planId":"a1b2c3d4","starred":true}'
switchboard api DELETE "/kanban/plans?planId=a1b2c3d4" # DB row only
switchboard api DELETE "/kanban/plans?planId=a1b2c3d4&deleteFile=true" # also remove the file
workspaceRootvalidation:POST /kanban/plansandPOST /kanban/plans/importrequireworkspaceRoot(if provided) to match a known registered workspace root as reported byGET /health→roots. Any unregistered root (or a differently-cased / symlinked spelling that does not match) is refused with400.
delete_plangotcha: withoutdeleteFile=true, the.mdfile stays on disk and the plan re-appears on the nextimport_plans(or a webview reset). UsedeleteFile=truefor a permanent delete.create_planrefuses to overwrite an existing file (409) and rejects path-traversal slugs (400).
4. Board mutations (POST)
| Endpoint | Body | Purpose | |
POST /kanban/dispatch | { plan: planId or plan-file path, targetColumn?, workspaceRoot?, from? } | ONE-call advance-and-dispatch. targetColumn omitted/"auto" → routed by plan complexity through the board's own rule (default bands 1–4 → INTERN CODED, 5–6 → CODER CODED, 7+/unknown → LEAD CODED; custom routing maps + pair-mode bypass honored; decision returned in routing). Canonicalizes explicit columns, persists the move, fires the column's role prompt (CLI-triggers setting does not gate API dispatches), verifies vs DB. Honest response (moved, dispatched, dispatchedAt); 4xx/409 when it can't work (no role on column, no live terminal agent). Prefer this over move + raw triggerAction (whose exact webview field names and hollow {success:true} acks hide no-ops). from — your own terminal name. Supply it and a role dispatch (e.g. CODE REVIEWED → reviewer) is routed to the member of YOUR team rather than the first matching terminal on the board. The response echoes teamRouting naming the decision, including when it fell back | |
POST /kanban/queue/next | { from, workspaceRoot? } | The coding lead's pull door onto the same dispatch machinery POST /kanban/dispatch is. Pops the next staged card (STAGING column only; subtasks excluded via empty featureId), dispatches it through performKanbanDispatch with targetTerminalOverride: from (complexity routing picks the column, the requesting head is the terminal — the lead asked, the lead receives, and it delegates subtasks itself), and returns what it dispatched. The head is the expected caller — call this after you have posted POST /kanban/task/complete for the card your team holds. The board never refuses a dispatch: a seat still holding a card does NOT produce a 409 — ownership is advisory display metadata, and the dispatch itself resets the owner stamp and completed_at. Two response shapes: 200 { success:true, dispatched:{...} } (a card was popped and dispatched — work it) and 200 { success:true, dispatched:null, reason:'queue empty' } (no eligible staged cards remain — report and stop). The pop is serialized per-process so two heads asking at once never receive the same card. A failed dispatch (no live terminal, card dragged out) is passed through and the card stays staged. from is the head's own terminal name; an unresolvable from is a 400. The route is a thin wrapper over the in-process dispatchNextFromQueue method — the schedule timer and the handoff call the method directly so the serialization chain is the single critical section | |
POST /kanban/move | { planId or sessionId, targetColumn, workspaceRoot? } | Move a card (feature cascade + tracker sync inherited). Column IDs are canonical uppercase (LEAD CODED), never state-file slugs (lead-coded) — both endpoints canonicalize and 400 on unknown columns. workspaceRoot omitted ⇒ resolved by identity across all registered roots (GET /health → roots); supplied ⇒ that root or a not-found error. Response echoes resolvedWorkspaceRoot and rootResolution (explicit/default/searched/path). Unavailable (503) on the standalone host | |
POST /kanban/feature | { name, planIds: [...], description?, workspaceRoot? } | Create a feature from plan IDs | |
POST /kanban/feature/assign | { featurePlanId, planIds: [...], workspaceRoot? } | Assign plans to a feature | |
POST /kanban/feature/remove | { subtaskPlanId, workspaceRoot? } | Detach a subtask from its feature | |
POST /kanban/feature/delete | { featurePlanId, deleteSubtasks?, workspaceRoot? } | Delete a feature | |
POST /kanban/feature/split | { featurePlanId, keptPlanIds: [...], firstFeatureName, secondFeatureName, workspaceRoot? } | Split a feature in two | |
POST /kanban/task/complete | `{ from, planId? | ordinal?, outcome?, note?, workspaceRoot? }` | Assert a plan is FINISHED. Writes completed_at on the row; outcome/note are recorded when supplied and are never required — the post itself is the completion signal. Idempotent within a dispatch: a repeat while completed_at is newer than the last dispatch stamp returns the existing timestamp. Dispatch resets completed_at, so a post after a re-dispatch counts as fresh work. The ONLY fact that marks work done; nothing else infers doneness. planId and ordinal are mutually exclusive; when both are absent the server resolves the poster's acceptance candidates — ordinal indexes the held feature's subtask order (the number in the feature file's Subtasks list), and a bare post resolves exactly one incomplete candidate or 400s naming them |
POST /worktree/feature | { featureId, featureTopic?, repoName?, workspaceRoot? } | Create the per-feature worktree (branch + tree + worktrees row + best-effort terminal seating). The ONE implementation, shared with the webview's per-feature create button. featureTopic names the branch and is resolved from the board when omitted. A feature that already has an active worktree returns 200 { success:false, error:"Feature already has worktree: <branch>" } — not a 4xx, so a loop over several features reports that one and continues. Never writes feature_worktree_mode | |
POST /worktree/cleanup | { worktreeId or branch, workspaceRoot? } | Mark a worktree merged and clean it up (kind-aware) | |
POST /mission-control/adopt | { workspaceRoot?, terminalName? } | The caller IS Mission Control. Records the seat and returns { mode, prompt, seat, liveDelivery, note? } — the same pre-flight prompt /mission-control/start would have injected into a terminal it created, handed back so you run it in your own session. Seats no terminal. Does not arm — arming is POST /mission-control/confirm. Pass terminalName from $SWITCHBOARD_TERMINAL when set; omitted or unmatched → liveDelivery: false and turn-end notices arrive in the reports inbox instead | |
POST /mission-control/start | { workspaceRoot? } | Two paths, decided by whether a lead/coder agent is configured. Terminal mode: create a pty terminal named Mission Control, boot the lead/coder CLI into it, and deliver the pre-flight interview (the agent adopts the seat itself via POST /mission-control/adopt — the server does NOT seat). Clipboard mode (no agent configured, or pty unavailable): NO terminal is created; returns { mode:'clipboard', prompt } with the /switchboard launcher text for the caller to run. Does not arm in either path — the terminal-mode message says Mission Control is seated and awaiting confirmation; arming is POST /mission-control/confirm. Double-click protection: a second call while an Mission Control terminal is already live redelivers the persona prompt to it instead of spawning a second terminal | |
POST /mission-control/confirm | { workspaceRoot? } | Arm a Mission Control session after the pre-flight. Verifies .switchboard/mission-control/session.md exists, then arms Mission Control switch (sets missionControlArmed; the schedule switch is independent and may also be on). Returns { success, sessionFile } or { success:false, error } when session.md is absent. The only path that arms | |
POST /mission-control/handoff | { headTerminal, stagedCount?, firstCardPlanId?, summary, workspaceRoot? } | Hand off Mission Control to a coding lead and exit (default pre-flight exit). Refuses with 409 if no live coding head, if queue is empty, if already armed, or if already handed off. Writes summary to session log, closes Mission Control seat, and leaves automation state untouched | |
POST /mission-control/stop | — | Disarm Mission Control and archive session.md to sessions/session-<ISO>.md |
# Column vocabulary: CREATED | PLAN REVIEWED | STAGING | LEAD CODED | CODER CODED | INTERN CODED
# | CODE REVIEWED | COMPLETED (see GET /kanban/columns)
switchboard api POST /kanban/move '{"planId":"a1b2c3d4","targetColumn":"CODE REVIEWED"}'
switchboard api POST /kanban/feature '{"name":"Auth Refactor","planIds":["id1","id2"],"description":"Group the auth work."}'4a. Verb-rail traps (read before you call POST /<panel>/verb/<name>)
The generic verb rail (POST /<panel>/verb/<name>) routes webview messages through
the same handler a UI click takes. Two traps hide on this surface, and they share
one failure mode: the route answers {success:true} and nothing happens. An
agent cannot detect the no-op from the response, so it reports success and moves
on — the most expensive class of bug on this surface.
Trap 1 — read verbs return only {success:true}
Read verbs (get* / fetch* / load*) over the generic rail return only
{success:true} — their data arrives on the WS hub, which an HTTP client
cannot see. The call looks successful and returns nothing.
Use the dedicated GET endpoints instead: GET /kanban/board,
GET /kanban/plans, GET /kanban/plan?planId=<id>. They return the data in
the response body (wrapped in .data).
Trap 2 — exact webview field names
Raw verbs expect the exact webview message field names. The two that bite:
triggerActiontakes{ sessionId, targetColumn }— notplanId, notcolumn.promptOnDroptakes{ sessionIds, sourceColumn, targetColumn }— notplanId, notcolumn.
Wrong names (planId, column) make the arm silently no-op while the route
layer still answers {success:true} — the call LOOKS successful and nothing
happened.
Prefer the first-class endpoints (POST /kanban/dispatch, POST /kanban/move)
— they validate, canonicalize columns, verify against the DB, and return honest
errors. Use raw verbs only when no endpoint exists, and verify the effect
afterwards (GET /kanban/plan?planId=<id> → check dispatchedAt and
kanbanColumn).
4b. Prompt delivery (POST /terminals/verb/*) — attended coder driving
When you are a head agent driving a coder terminal (dispatch a subtask, get called back,
review the diff, resend a fix) — not running an unattended column sweep — use the
prompt-delivery verb pair. The behavioral rules are inlined in the enriched
drive prefix built by _buildDrivePrefix in KanbanProvider.ts; the endpoints are:
| Endpoint | Body | Purpose |
POST /terminals/verb/ptySendPrompt | { name, data, clearBeforePrompt, origin?, dispatch?, kind?, machineOrigin? } | Deliver a prompt to a named terminal. Pass clearBeforePrompt: false explicitly — the omitted-field default has moved once already; if it moves back, every send wipes the coder's conversation. Both hosts apply standing orders (the callback contract) on a dispatch payload only — see the kinds below; a send that declares no kind and carries no dispatch object is read as a message and gets none of the three appends. Payload kinds:<br>• kind: "dispatch" — seat starting work; receives standing orders, seat directive block, and dispatch directives.<br>• kind: "message" (or legacy alias machineOrigin: true, to be retired once coder standing-order texts are reissued) — questions, instructions, relayed messages, and notifications; suppresses all three appends.<br>• kind: "orders-refresh" — reserved for after-clear delivery path (rejected if sent).<br>Omitted kind defaults to dispatch if dispatch object is supplied or if prompt text contains dispatch identity (PLANS TO PROCESS:), and defaults to message otherwise. Dispatching a subtask? Pass dispatch: { planFile|planId, role } — the host then registers the dispatch before delivering and attaches the protocol directives the board attaches (plan-file completion report + Mission Control reports directive), echoing attributed and directivesAttached. Without it the coder is never told to write a completion report, so a finished subtask reports nothing the board can see. Fails closed: attributed: 0 → success: false, nothing delivered. Never send dispatch on a plain message or on a report back to your head — it would make the recipient write a plan file and fire a false completed. The dispatch-protocol directives (COMPLETION REPORT, ORCHESTRATOR REPORT) are appended automatically for coder/intern/lead recipients — do not paste your own. Messaging or dispatching from your own seat? Pass origin: "<your own terminal name>" — the roster barrier clears the rest of the team when a new feature enters it, and origin is how it knows not to clear you. Without it a lead can wipe its own context with its own dispatch; the busy check only saves a seat that happens to be emitting output at that instant. Omit it only when you are relaying a human's board action. |
POST /terminals/verb/ptyListTerminals | {} | Enumerate live terminals: { terminals: [...] } — one array, every live terminal. Copy friendlyName verbatim. |
POST /terminals/clear | { from, name? | team? | seats? } | Canonical first-class clear endpoint. Clears a single seat (name), team roster (team), or explicit set (seats). Enforces server-side invariants: from is required and never cleared; team scope preserves the lead and defers mid-turn seats; redelivers standing orders; rolls log session boundary. Returns { success: true, cleared: [...], deferred: [...], skipped: [{ name, reason }] }. |
(Note: POST /terminals/verb/ptyClearTerminal remains as a low-level host verb used internally, but POST /terminals/clear is the canonical agent-facing endpoint.)
ptySendPrompt delivery evidence & response fields
success: true: Bytes were written to the pty and the submit CR was sent. It is not an echo, not a round trip, and not a claim that the CLI parsed anything.bytesWritten: UTF-8 byte length of text written to the terminal, including host-appended directive, seat, and standing-orders blocks. It is expected to exceed the length of yourdatafield. A larger number is not evidence of corruption.promptSeq: That seat's delivery ordinal (post-incrementpromptCount). Re-readingpromptCountonptyListTerminalsand seeing it advance past yourpromptSeqmeans a later send landed, not that yours did — your own evidence is thepromptSeqon your own response.lastDataAt: The seat's output heartbeat fromptyListTerminals. It proves the CLI emitted something; it never proves the CLI received something.
Clear a coder the moment you stand it down, not on the way back in — the precondition is completion received and next work assigned elsewhere, and never clear your own terminal (see the inlined "Clear a terminal only when at rest" rule in the drive prefix).
Terminal Reset & Clear Protocol — why /clear via ptySendPrompt fails
Agents frequently try to clear a terminal by sending /clear through
ptySendPrompt. This does not work, pollutes the prompt, and is rejected.
ptySendPrompt wraps data in bracketed-paste escape sequences
(\x1b[200~ ... \x1b[201~). A CLI receiving bracketed-paste /clear treats it
as literal text input, not a slash command. Inside the block it is absorbed as
literal text and silently prefixes or pollutes the payload with appended orders.
Sending bare slash commands like /clear via ptySendPrompt is rejected by the
server with an error directing callers to POST /terminals/clear.
The canonical mechanism is POST /terminals/clear:
# Clear a single seat
switchboard api POST /terminals/clear '{"name":"<terminal friendlyName>","from":"<your terminal name>"}'
# Clear team terminals
switchboard api POST /terminals/clear '{"team":"<head terminal friendlyName or teamId>","from":"<your terminal name>"}'Rules:
fromis required on all calls to prevent accidental caller context wipe.- Clear a coder the moment you stand it down, not on the way back in. A clear
issued at rest is what resets a coder you always send with
clearBeforePrompt: false, and it lands long before the next dispatch instead of racing it. - Never clear your own terminal.
- Never use
ptyClearAllTerminals(it clears every active terminal, you included).
5. Comms (POST)
POST /comment—{ provider, id, body }: reply to a Linear/Notion/ClickUp remote-control card.POST /phone-a-friend—{ planFile, originRole? }: notify a second-pass terminal at batch end.
6. Integration proxies (POST)
Passthrough to the tracker APIs using Switchboard's stored tokens (you never see the tokens):
POST /api/clickup,POST /api/linear— raw API proxy.GET /resolve/<clickup|linear>/name/<name>,GET /metadata/<clickup|linear>,/task/*,POST /doc/clickup,POST /diagram/generate— see theclickup_api/linear_apiskills.
7. Workflow A — fleet coding/review agent inside a worktree
You were dispatched into a worktree to code or review one plan. You report back to your head
agent via ptySendPrompt (installed as a standing order on every team member). You do not
have a chat channel to Mission Control; you use HTTP for board reads.
# 1. Find your plan (its planId is in your dispatch prompt) and read its full spec.
switchboard api GET "/kanban/plan?planId=$PLAN_ID" --json | jq -r '.result.data.content'
# 2. Do the work in this worktree. Commit as you go (Mission Control verifies via git, not chat).
# 3. Report back to your head agent when done (the standing order installed on dispatch).
# The head agent or the extension's turn-end notifier will signal Mission Control.You do not move your own card or merge — Mission Control does that after verifying your git state.
8. Workflow B — external Mission Control driving the board
You are an external agent acting as Mission Control. Mirror the in-VS-Code persona
(switchboard-mission-control): coding + code-review only; planner-stage questions escalate to the human.
# 1. Read the whole board on each wake.
switchboard api GET /kanban/board --json | jq '.result.data'
# 2. Group loose plans into features (the external equivalent of group-into-features).
switchboard api POST /kanban/feature '{"name":"Checkout v2","planIds":["id1","id2","id3"]}'
# 3. For each feature, read its subtasks + message the team leads (or dispatch to your own agents).
switchboard api GET "/kanban/plans?featureId=$FEATURE_ID" --json | jq '.result.data'
switchboard api POST /terminals/verb/ptySendPrompt "{
\"name\":\"<lead terminal friendlyName>\",
\"data\":\"You are leading the <feature name> feature. Your PLAN REVIEWED subtasks are: <list>. Implement each, commit, and report back when done.\",
\"origin\":\"<your own terminal friendlyName>\",
\"clearBeforePrompt\":false,
\"kind\":\"dispatch\",
\"dispatch\":{\"planId\":\"<featurePlanId>\",\"role\":\"lead\"}}"
# 4. On each wake: VERIFY VIA GIT, not self-report. A subtask is "coded" only when its worktree
# branch is ahead of base with committed work. Use the base_branch from GET /worktree/list.
switchboard api GET /worktree/list --json | jq -r '.result.data[] | [.path, .base_branch] | @tsv' |
while IFS=$'\t' read -r wt base; do
echo "$wt: $(git -C "$wt" rev-list --count "${base:-main}"..HEAD 2>/dev/null) commits ahead"
done
# 5. Advance verified cards; escalate planner-stage questions to the human.
switchboard api POST /kanban/move "{\"planId\":\"$PLAN_ID\",\"targetColumn\":\"CODE REVIEWED\"}"
# 6. When all of a feature's subtasks are CODE REVIEWED, merge the feature's single shared
# worktree branch into main (per-feature model: one worktree per feature, so one merge —
# git -C <main checkout> merge <feature branch>), then clean up the worktree.
switchboard api POST /worktree/cleanup "{\"branch\":\"$BRANCH\"}"9. The truth rule
Trust git and board state, never an agent's self-reported "done":
- Coded = worktree branch ahead of base with committed work (
git -C <wt> rev-list --count <base>..HEAD > 0) and a clean tree (git -C <wt> status --porcelain). - Reviewed = the review stage genuinely passed and the card sits in
CODE REVIEWED. - Read board state via these endpoints; read ground truth via your own
gitcommands.
9a. Fleet Completion Detection Recipe
When an operator asks you to monitor a fleet until completion, three concrete signals define "done". Check them in order; the first non-null/stopping signal wins.
- Database signal —
completedAt.completed_atis non-null on the plan record (written byPOST /kanban/task/completeviasetCompletedAt). Read it viaGET /kanban/plan?planId=<id>→.data.completedAt. NULL means the team is still working — do not report completion.- The
completed_atwrite is idempotent — a repeatPOST /kanban/task/completefor the same planId returns the existing timestamp without re-writing. When polling, a stable timestamp is not a new event; do not re-report. outcomeis optional — a post with nooutcomestill completes the card. The post itself is the completion signal; prose does not gate it.
- The
- Queue signal —
POST /kanban/queue/next. Returns200 { success: true, dispatched: null, reason: "queue empty" }when no eligible staged cards remain — report and stop. (The response is wrapped in the standard success envelope; an agent parsing for a bare{ dispatched, reason }object will not find it underres.dataand may misread the response.)- There is no in-flight refusal: a card your team still holds does not block
the pop. A re-dispatched card resets its
completed_atand owner stamp; if the work is already done the dispatched agent reads the plan and says so — that is the intended cheap path, not an error. - To leave a resume hint for the next dispatch, append a
checkpointevent (switchboard verb checkpoint '{"planId":"<id>","text":"…"}'). The next dispatch surfaces it in the prompt as advisory resume context; it never gates dispatch.
- There is no in-flight refusal: a card your team still holds does not block
the pop. A re-dispatched card resets its
- Git signal —
Switchboard-Plantrailer. A commit on the integration branch (mainor the feature's shared worktree branch) carrying the git trailerSwitchboard-Plan: <planId>(one line per planId in a batch dispatch), preceded bySwitchboard-Stage: <stage>. The trailer block requires a blank line before it (git only parses trailers in the message's final paragraph). Verify with:git log --format='%(trailers:key=Switchboard-Plan,valueonly)'
All three are ground truth; an agent's self-reported "done" is not. Trust the database timestamp, the queue response, and the git trailer — never a chat claim.
10. Failure modes
SWITCHBOARD_NOT_RUNNING— port file missing or/healthfails → tell the user to start the extension. Never editkanban.dbdirectly.404— plan/feature/worktree not found (bad id).400— invalid input (bad column, empty body, path-traversal slug); the message names the problem.409—create_planslug already exists.503withcode: "STORE_UNAVAILABLE"— the board or archive store did not answer (tiersays which). This is NOT an empty board and NOT a missing card. Retry once after a few seconds, then stop and report — do not loop on a store that is down, and do not act as though the board were empty.503without that code — extension not ready yet → retry after a short delay.
11. File-based fallback (no HTTP)
If the API server is down you can still communicate via the filesystem (Mission Control reads these):
- Session file:
.switchboard/mission-control/session.md— the current session file (Rules + append-only Log); read it to see Mission Control's decisions. The legacy.switchboard/mission-control/session-log.mdis still honoured as a fallback byGET /mission-control/session-logon installs that have one. - Progress:
.switchboard/mission-control/progress.json— Mission Control's per-plan stall state.
Reports channel — switchboard reports
A report is a host-recorded turn-end event stored in the plan_events table (event_type turn_end), not a file. The host writes a row on every turn-end (finished, blocked, stalled), joined to the card's current kanban column. This is not an HTTP surface — there is no GET /mission-control/reports endpoint. Read reports via the CLI:
switchboard reports [--kind blocked|finished] [--limit N] [--json]--kind blockedfilters to blocked turn-ends (a seat went quiet or a feature stalled).--kind finishedfilters to finished turn-ends (a seat completed its turn).- The output includes each card's current kanban column, so you can tell whether a formerly blocked card is still blocked.
- A row whose card was deleted or archived still appears, with
[no card]— the record survives its card. - Rows are pruned by the retention service; the accumulation that 1900+ files became cannot recur.
- Every row on this channel is host-written; the row carries no
fromfield. Agent-authored reports are a separate path (team reports viaGET /teams/<id>/reports), not this channel. - The old file-based channel (
.switchboard/mission-control/reports/) is retired. Existing files remain as archival evidence; no code reads or writes them.
Notes
- localhost only (127.0.0.1) — never a public interface.
- Reads wrap payloads in
.data; mutations return{ success, ...fields }. - This surface is documented for external tools; the in-VS-Code Mission Control persona is
switchboard-mission-control.
Remote & Mobile Network Addressing (Tailscale)
When the operator machine is reachable over Tailscale, the board and terminals
are addressable at http://100.x.y.z:7777 (the Tailscale IP, port from the live
server). SSH access to the host requires key-based authentication when
password auth is hardened — the operator must have a registered SSH key, not a
password.
Tailscale IS the network boundary. The API server must never be exposed on a public interface. Tailscale's encrypted mesh is the controlled boundary that replaces exposing a port to the open internet; it is not a hole in the localhost boundary, it is the boundary itself.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

