Files
turnstone/docs/api-reference.md
T
Patrick Buckley 480a1426b3 Fail-closed history-commit handoff (#1005)
* fix(session): fail-closed history-commit handoff (#981)

The deleted-workstream discovery is now a terminal, ws_id-keyed latch:
keyed conversation commits refuse admission once the durable parent is
gone (convergence finalizers and force-abandon are exempt), history
handoff refuses to mint a proof token so /history fails closed with a
503 instead of silently wiping the pane, and the SSE stream carries a
workstream_gone resync reason. Discarded commits leave a forensic log
of commit keys and roles, never content.

Conversation rows gain a commit_key (migration 071): keyed saves are
idempotent under retry, validated against the full commit identity, and
refused when they would cross a workstream deletion. The prune orphan
category now requires a NULL alias plus a two-hour updated grace, with
cutoffs computed at discovery time and carried into both dialects'
rechecks.

The mid-turn interjection queue is owner-partitioned with no per-site
mode flags: pops take the acting principal's and unowned rows, other
participants' rows are structurally retained, and enforcement lives at
queue admission plus the shared before_spawn gates. The retraction
ledger is bounded by open pop windows: pops open a window atomically
with the queue delete, restores close their ids atomically with the
ledger consume, every other exit closes through one helper, and misses
for unheld ids record nothing. The workstream-gone latch refuses
unattended wakes at all three gates (watcher spawn, claim, delivery
pre-pop), and the retry dispatcher regained its pre-envelope
cancel/error convergence net.

Persistence-state reporting derives through the session bound to each
UI instead of a registry lookup by id that failed open to healthy
during tombstone retention. The dashboard roster no longer re-inserts
ghost entries from trailing activity events, the history tool-outcome
scan tolerates interleaved non-turn rows, and the shared
handoff-deadline handle owns its own retirement.

Single-sourced across call sites: keyed-commit row values, attachment
save wrappers, tail-truncation and conflict-resolution bodies for both
storage dialects; worker-slot lifecycle field sets; the direct-commit
admission frame; queued-row layout accessors; the string-aware comment
stripper shared by every JS harness suite.

Refs #981 #964

* fix(session): sweep handoff fixes to their sibling surfaces

The interactive replay loop treated a system row as a tool-batch
boundary, so every tool result after an interleaved row vanished from
that pane while the coordinator rendered the same history correctly.
Only a conversational turn ends the batch window now, matching the
shared outcome index.

Accepted user turns clear the composer's attachment chips on the same
viewer policy that settles optimistic bubbles rather than on having
matched a local bubble, so a workstream created with an upload no
longer keeps a chip for an attachment the create dispatch already
consumed. The coordinator's raced-Stop arm emits the stream-end hook it
inherits alongside the idle state, leaving no unfinalized bubble or
unflushed tool output. Ending a session surfaces a failure toast when
the request never lands or answers with a non-JSON body.

The per-second persistence reconcile now probes each session without
blocking: a workstream whose generation and handoff locks are held is
skipped until the next pass instead of contending the locks every
commit needs. The one-shot repair that gates workstream creation at
capacity keeps a definite probe — it has no next pass, and the sessions
likeliest to be contended are the ones whose unresolved journals
emptied its candidate list.

Single-sourced: the attachment lane builds its conversation row through
the shared commit-identity builder; the ordinary worker exit releases
its slot through the lifecycle owner; both operator surfaces snapshot
their counters through one non-consuming helper; the replay preamble
loses its per-kind wrappers and its config hook; the browser harness
suites share one brace walker; and each in-flight history attempt is
one record carrying both its abort controller and its deadline.

Refs #981 #964
2026-08-11 04:18:36 -07:00

99 KiB
Raw Blame History

turnstone Web Server API Reference

Overview

turnstone-server exposes a browser-based chat UI backed by a Starlette ASGI application served by uvicorn. The server uses Server-Sent Events (SSE) via sse-starlette for real-time streaming and HTTP POST for user actions.

All API responses use Content-Type: application/json unless otherwise noted. CORS headers (Access-Control-Allow-Origin: *) are included on every response.

The server supports multiple concurrent workstreams (tabs), each backed by an independent ChatSession and event queue.


API Versioning

All API endpoints use the /v1/ prefix. Non-API endpoints (/, /health, /metrics, /openapi.json, /docs, /static/*, /shared/*) are unversioned.

Interactive Documentation

  • OpenAPI spec: GET /openapi.json — machine-readable OpenAPI 3.1 schema
  • Swagger UI: GET /docs — interactive API explorer (loads from CDN)

Client SDKs

Typed client libraries for programmatic access to both the server and console APIs.

Python (included in the turnstone package):

from turnstone.sdk import TurnstoneServer

with TurnstoneServer("http://localhost:8080", token="tok_xxx") as client:
    ws = client.create_workstream(name="demo")
    result = client.send_and_wait("Hello!", ws.ws_id)
    print(result.content)

Async variant: AsyncTurnstoneServer / AsyncTurnstoneConsole.

TypeScript (sdk/typescript/):

import { TurnstoneServer } from "@turnstone/sdk";

const client = new TurnstoneServer({ baseUrl: "http://localhost:8080", token: "tok_xxx" });
const ws = await client.createWorkstream({ name: "demo" });
const result = await client.sendAndWait("Hello!", ws.ws_id);
console.log(result.content);

Authentication

Auth is always enabled. All API endpoints except public paths require a valid token.

Sending Credentials

Include a token in one of two ways:

  • Bearer header: Authorization: Bearer <token>
  • Cookie: the surface-scoped auth cookie — turnstone_auth_server on turnstone-server, turnstone_auth_console on turnstone-console (set automatically by the login endpoint). The names differ so the two surfaces, when co-hosted on one origin, don't overwrite each other's session.

The server accepts two token types:

Type Format Example
JWT Base64 segments separated by dots eyJhbG...
API token ts_ prefix + 64 hex chars ts_a1b2c3d4...

JWTs are the recommended credential for browser sessions. API tokens are suitable for programmatic access and CI/CD.

POST /v1/api/auth/login

Authenticate with credentials and receive a JWT. Accepts two credential formats:

Username + password:

{"username": "alice", "password": "hunter2"}

API token:

{"token": "ts_a1b2c3d4e5f6..."}

Response (success): 200

{
  "status": "ok",
  "role": "full",
  "scopes": "approve,read,write",
  "jwt": "eyJhbGciOiJIUzI1NiIs...",
  "user_id": "u_abc123"
}

The response also sets a surface-scoped HttpOnly cookie containing the JWT (turnstone_auth_server on turnstone-server, turnstone_auth_console on turnstone-console).

Response (failure): 401

{"error": "Invalid credentials"}

POST /v1/api/auth/logout

Clears the surface-scoped auth cookie (turnstone_auth_server / turnstone_auth_console). No request body required.

Response: 200

{"status": "ok"}

The response includes a Set-Cookie header that expires the auth cookie.


GET /v1/api/auth/status

Returns the current authentication state. Works with or without a valid token.

Response (authenticated): 200

{
  "authenticated": true,
  "user_id": "u_abc123",
  "scopes": ["approve", "read", "write"],
  "source": "jwt"
}

Response (not authenticated): 200

{
  "authenticated": false,
  "user_id": null,
  "scopes": [],
  "source": null
}

Response (auth disabled): 200

{
  "authenticated": false,
  "auth_enabled": false
}

POST /v1/api/auth/setup

Creates the first admin user when no users exist in the database. This is a public endpoint (no authentication required) that only succeeds when auth is enabled and the user database is empty. Both the server and console expose this endpoint.

Request body:

{
  "username": "admin",
  "display_name": "Admin",
  "password": "strongpass"
}
Field Type Required Validation
username string yes 1-64 ASCII characters
display_name string yes Non-empty
password string yes Minimum 8 characters

Response (success): 200

{
  "status": "ok",
  "user_id": "u_abc123",
  "username": "admin",
  "role": "full",
  "scopes": "approve,read,write",
  "jwt": "eyJhbGciOiJIUzI1NiIs..."
}

The response also sets a surface-scoped HttpOnly cookie containing the JWT (turnstone_auth_server on turnstone-server, turnstone_auth_console on turnstone-console).

Response (already set up): 409

{"error": "Setup already completed"}

Returned when one or more users already exist in the database.

Response (auth disabled): 400

{"error": "Auth is not enabled"}

Endpoints

GET /

Serves the embedded single-page application (HTML, CSS, and JavaScript inlined in a single document). The SPA connects to the SSE and POST endpoints listed below.

Response: text/html; charset=utf-8


GET /v1/api/workstreams/{ws_id}/events

Opens a Server-Sent Events stream scoped to a single workstream. The connection remains open indefinitely; the server pushes events as they occur.

Path parameters:

Parameter Type Required Description
ws_id string yes Workstream identifier

Error: Returns 404 with {"error": "Unknown workstream"} if ws_id is not recognized.

Connection lifecycle

  1. connected -- sent in the synthetic replay for a fresh connection (and after an announced replay gap). A cursor reconnect whose buffered gap is fully covered receives only the missing buffered events, so this preamble is not duplicated.
{
  "type": "connected",
  "model": "kappa_20b_131k",
  "model_alias": "default",
  "skip_permissions": false
}

skip_permissions reflects the workstream's blanket auto-approve state. It is true if the server was started with --skip-permissions or the workstream was created with blanket approval. "Approve + Always" now remembers only the tool names from the resolved cycle and does not flip this field.

  1. REST history bootstrap -- the SSE stream does not carry the full transcript. Before opening a pane's initial event stream, fetch GET /v1/api/workstreams/{ws_id}/history?limit=100 (limit is clamped to 1--500). This also works for a saved workstream that is not loaded in the manager.
{
  "ws_id": "abc123",
  "messages": [
    {"role": "user", "content": "Hello"},
    {"role": "assistant", "content": "Hi there!", "tool_calls": null},
    {"role": "tool", "content": "..."},
    {"role": "system", "source": "compaction", "content": "Summary..."}
  ],
  "cursor": null,
  "handoff_token": "opaque-live-revision"
}

cursor is normally null. When history intentionally trims a still-running trailing turn that the event ring can reconstruct, open the SSE URL with ?last_event_id=<cursor> (or send Last-Event-ID) so the buffered delta fills that turn without double-rendering it.

For a loaded workstream, messages is the requested tail projection of one authoritative total accepted conversation-row prefix. It includes user, assistant, tool, and system rows, including compaction checkpoints projected as role: "system", source: "compaction" and cancellation-generated partial assistant or synthesized tool-result markers when present.

handoff_token is non-null exactly when the workstream's session is live on the serving node (a pane opened it there); it identifies the exact total prefix used for that render. Pass it once on the initial SSE URL as history_token. The token is opaque and process-local: do not parse, persist, or reuse it. Admission of any later conversation row changes the token; moving a row from the pending journal to durable storage does not. The server validates the token while atomically registering the listener. A mismatch emits history_resync and closes the stream; fetch history again instead of replaying from a numeric cursor. A native reconnect's Last-Event-ID header takes priority and follows the normal ring-replay path.

A null handoff_token on a 200 is the cold storage-only read: the workstream is not loaded on the serving node, so there is no live writer and no splice to witness. The payload may seed a render and a token-less stream bootstrap (the server converges the pane through clear_ui), never a cursor handoff. /history never loads a session — reading an archived transcript leaves the session pool untouched.

Each message in the messages array has:

Field Type Description
role string "user", "assistant", "tool", or "system"
content string or null Text content of the message
tool_calls array or null Present only on assistant messages with calls
source string (optional) Operator-context or marker source, including "compaction"
meta object (optional) Structured display metadata for the source
attachments array (optional) Accepted attachment metadata: attachment_id, kind, filename, and mime_type
sender string (optional) Authenticated participant attributed to an accepted user row
client_send_ids string[] (optional) Optimistic-send correlation tokens carried by an accepted user row; never idempotency keys
tool_call_id string (tool only) Provider correlation id for the corresponding assistant call; ids may be reused across turns
tool_name string (tool only) Function name for the tool result
event_id integer (optional) Accepted SSE row identity used for replay deduplication
is_error bool (tool only) Final error disposition
effect_status string (optional) Persisted effect disposition when known
preview object (optional) Content-addressed preview descriptor; does not contain preview bytes
reasoning string (optional) Concatenated reasoning / chain-of-thought text on assistant turns whose provider_data carried reasoning-bearing blocks (Anthropic thinking, OpenAI Responses reasoning, or synthetic reasoning_text from local-model servers). Present only when the active model's surface_persisted_reasoning flag is True.

Each entry in tool_calls:

Field Type Description
name string Function name (e.g. "bash")
arguments string JSON-encoded argument string

Streaming events

After the synthetic replay or cursor delta, the server streams real-time events as the model generates a response:

Typed accepted-user projection is capability-gated. Add ?user_turn=1 to every per-workstream SSE URL to receive user_turn; the embedded panes and both SDKs do this automatically. A raw client that omits the capability receives a replay_truncated frame with reason user_turn_projection_unsupported, whose SSE id is anchored immediately before the unrepresented row. It must fetch and render /history before reconnecting. This backward-compatible repair frame does not expose the row content, and a failed history fetch must retain the pre-row cursor so the repair signal repeats.

Final accepted-tool projection is separately capability-gated. Browser panes add tool_turn=1 to every per-workstream SSE URL, including every manual and native reconnect. A capable listener receives a second tool_result with accepted: true, the row's _event_id, and the final scalar text, error, preview, and effect fields that entered accepted history. Reducers replace the earlier executor receipt in place. A client that omits tool_turn=1 receives a redacted replay_truncated frame with reason tool_turn_projection_unsupported, anchored at the cursor immediately before the accepted row, and must rebuild from /history.

This accepted projection is a transcript-consistency mechanism, not a wire confidentiality boundary. The preliminary tool_result is deliberately sent as soon as execution completes and can precede post-execution output transforms; do not treat accepted: true as proof that earlier frames contained the same text.

user_turn -- the canonical accepted user row. Every upgraded listener on the shared workstream receives the event, including peer browsers, so peers can render the turn without refetching all history. The originating pane uses client_send_ids only to replace or mark its exact optimistic bubble; peers render the row once by SSE event id. Reusing a client token still admits and emits a distinct turn.

{
  "type": "user_turn",
  "ws_id": "abc123",
  "content": "Inspect this file",
  "attachments": [
    {
      "attachment_id": "a1",
      "kind": "text",
      "filename": "notes.txt",
      "mime_type": "text/plain"
    }
  ],
  "sender": "user-123",
  "client_send_ids": ["browserSend_42"],
  "_event_id": 17
}

client_send_ids is empty for callers that did not provide a correlation token. _event_id is the accepted row's monotonic SSE identity and is the deduplication key; client_send_ids is not. Correlation tokens are not credentials. When both identities are known, an upgraded pane settles a local optimistic bubble only when the event's sender matches that viewer; peer rows still render canonically without touching local optimistic state.

thinking_start -- the model has begun generating (shown as a spinner).

{"type": "thinking_start"}

thinking_stop -- the spinner phase is over.

{"type": "thinking_stop"}

reasoning -- a chunk of chain-of-thought reasoning text.

{"type": "reasoning", "text": "Let me think about this..."}

content -- a chunk of the assistant's visible reply.

{"type": "content", "text": "Here is the answer: "}

stream_end -- the model has finished generating. The client should finalize any in-progress assistant message.

{"type": "stream_end"}

state_change -- the worker thread transitioned to a new state. Drives the client's busy-mode (composer in send vs. stop, spinner indicators, auto-focus on idle). Sent live during normal operation AND on every fresh SSE subscribe (so a mid-stream page refresh restores the correct composer state without waiting for the next live transition).

{"type": "state_change", "state": "running"}
Field Type Description
state string One of "running", "thinking", "attention", "idle", "error"

in_progress_snapshot -- one-shot replay of the in-progress turn's content + reasoning text-so-far when this client connects mid-stream. Lets a refreshing browser tab restore partial assistant text immediately instead of waiting for the response to complete. Yielded once after the kind-specific replay preamble and pending-cycle snapshot, only when at least one of content / reasoning is non-empty. Both halves render into the same assistant bubble the live content / reasoning events would target; clients should treat the snapshot as idempotent (skip overwrite if the current local buffer is already a superset prefix — covers EventSource auto-reconnect re-replays).

{
  "type": "in_progress_snapshot",
  "content": "Here is the answer so far: it depends on ",
  "reasoning": "The user is asking about a comparison; let me think about..."
}
Field Type Description
content string Joined assistant content text accumulated this turn
reasoning string Joined reasoning / chain-of-thought text accumulated

tool_info -- one or more tool calls that were auto-approved (no user action required).

{
  "type": "tool_info",
  "items": [
    {
      "call_id": "call_abc123",
      "header": "bash: ls -la",
      "preview": "",
      "func_name": "bash",
      "approval_label": "bash",
      "needs_approval": false,
      "error": null
    }
  ]
}

approve_request -- one or more tool calls that require user approval. The client must respond via POST /v1/api/workstreams/{ws_id}/approve. Parallel task agents can leave several approval rounds pending on one workstream at the same time, so clients should echo the event's cycle_id (or one member call_id) when resolving it.

{
  "type": "approve_request",
  "cycle_id": "cycle_789",
  "items": [
    {
      "call_id": "call_def456",
      "header": "bash: rm -rf /tmp/build",
      "preview": "",
      "func_name": "bash",
      "approval_label": "bash",
      "needs_approval": true,
      "error": null
    }
  ]
}

cycle_id identifies this approval round. It is stable across reconnect replay and is also carried by the corresponding approval_resolved event.

approval_resolved -- one identified approval cycle was answered. Clients use cycle_id (or call_ids) to dismiss only that prompt when several remain live.

{
  "type": "approval_resolved",
  "cycle_id": "cycle_789",
  "call_ids": ["call_def456"],
  "approved": true,
  "feedback": "",
  "always": false
}

Each item in items (shared by tool_info and approve_request):

Field Type Description
call_id string Unique tool call ID (links chunks to results)
header string Human-readable header line for the tool call
preview string Diff or argument preview (may be empty)
func_name string Function name (e.g. "bash", "edit_file")
approval_label string Display label for the approval prompt
needs_approval bool Whether this call requires explicit approval
error string/null Error description if the call was malformed

tool_output_chunk -- incremental streaming output from a bash tool execution. Sent line-by-line as stdout is produced. The call_id identifies the specific tool invocation (multiple bash tools may run in parallel).

{"type": "tool_output_chunk", "call_id": "call_abc123", "chunk": "Building project...\n"}

tool_result -- output from a completed tool execution. The first event is the executor receipt. With tool_turn=1, a later event carrying accepted: true is the canonical accepted-history replacement and includes _event_id; preview and effect_status are present when persisted. The call_id matches the corresponding tool_info/approve_request item and any preceding tool_output_chunk events, but clients must scope reused ids to the newest rendered tool batch. For bash tools, the receipt arrives after all streaming chunks and includes both stdout and stderr. The is_error field is true when the tool execution failed (e.g. bash exit code >= 2 or signal, file not found, timeout). Exit code 1 is ambiguous (e.g. grep no-match) and is not flagged. User denials are tracked separately via a denied flag. Clients should use is_error instead of text-prefix heuristics.

{"type": "tool_result", "call_id": "call_abc123", "name": "bash", "output": "file1.py\nfile2.py\n", "is_error": false}
{"type": "tool_result", "accepted": true, "_event_id": 42, "call_id": "call_abc123", "name": "bash", "output": "file1.py\nfile2.py\n", "is_error": false, "effect_status": "unknown"}

status -- token usage statistics, sent after each model turn.

{
  "type": "status",
  "prompt_tokens": 1024,
  "completion_tokens": 256,
  "total_tokens": 1280,
  "context_window": 131072,
  "pct": 1.0,
  "effort": "medium",
  "cache_creation_tokens": 800,
  "cache_read_tokens": 200
}
Field Type Description
prompt_tokens int Tokens in the prompt
completion_tokens int Tokens generated by the model
total_tokens int prompt_tokens + completion_tokens
context_window int Total context window size in tokens
pct float Percentage of context window used
effort string Reasoning effort level (low/medium/high)
cache_creation_tokens int Tokens written to prompt cache (Anthropic + OpenAI)
cache_read_tokens int Tokens served from prompt cache (Anthropic + OpenAI)

info -- an informational message (e.g. command output).

{"type": "info", "message": "Session cleared."}

compaction -- context-compaction lifecycle (manual /compact and auto-compaction). phase: "start" opens the operation (trigger is "manual" or "auto"; auto adds where — e.g. "mid-turn" — and, when the percentage threshold actually fired, pct; the context-overflow retry path compacts without a pct since no threshold was evaluated). phase: "progress" reports chunked summarization (part/total/depth, where depth 0 summarizes transcript batches and deeper levels merge partial summaries), a transient-error retry wait (retry_in seconds + error), or warning: "summary_truncated". phase: "end" settles it: ok: true carries before_tokens/after_tokens and the produced summary; ok: false carries a reason ("not_enough_messages" / "irreducible" / "empty_summary" / "cancelled" / "error") and a human-readable message — for reason: "error" the same message is also emitted as a paired typed error event (that is the renderable error surface; the end event is card-teardown). Failed ends also carry notice: the emitter-computed display verdict — show message only when it is true (the server suppresses error-reason, superseded, and cancelled-auto notices once, centrally, so clients don't re-derive that policy). Every end (ok or failed) carries trigger, and every event carries compaction_id — an opaque integer correlating the start/progress/end of one compaction run (a client that force-stopped one compaction can use it to ignore stragglers from the abandoned run). End events also carry superseded: true marks a force-abandoned compaction retiring after a successor generation took over (an OK end's result card still stands: the history swap happened). Superseded start/progress events are never emitted. Exactly one start and one end are emitted per attempt, so clients can key an in-progress affordance (progress bar) on the pair. A successful end is also persisted: the summary replays from /history as a role: "system", source: "compaction" entry whose meta carries {watermark, before_tokens, after_tokens, trigger} and whose event_id matches the end event's id (dedup across repaint + replay).

{"type": "compaction", "phase": "start", "compaction_id": 7, "trigger": "auto", "where": "mid-turn", "pct": 80}
{"type": "compaction", "phase": "progress", "compaction_id": 7, "part": 2, "total": 5, "depth": 0}
{"type": "compaction", "phase": "end", "ok": true, "compaction_id": 7, "trigger": "auto",
 "before_tokens": 128400, "after_tokens": 9200, "summary": "## Decisions\n..."}

error -- an error message.

{"type": "error", "message": "Error: connection timed out"}

busy_error -- sent when a new message arrives while the model is already processing.

{"type": "busy_error", "message": "Already processing a request. Please wait."}

clear_ui -- instructs the client to clear displayed messages and re-fetch history after an identity or transcript-boundary change, including /clear, dedicated rewind/retry, successful fork publication, and opening saved history.

{"type": "clear_ui"}

history_resync -- the history rendered before this stream opened no longer names the live accepted conversation-row prefix. The server closes the stream after this event. Keep the current transcript visible, fetch /history again, render the successful response, and open a new stream with its new one-shot handoff token. A numeric event cursor cannot prove that a complete row was rendered and is not a substitute for this repair.

{"type": "history_resync", "ws_id": "abc123", "reason": "handoff_mismatch"}

ws_id is present for registration-time handoff mismatches; on an already scoped live stream, clients may infer it from the stream when omitted.

reason is a free string. workstream_gone means the workstream's durable row was deleted out from under a live session (by another node, or by startup cleanup); the follow-up /history fetch answers 503/404 rather than minting a new token, and new sends are refused.

cancelled -- a cancel request was acknowledged (via the Stop button or POST /v1/api/workstreams/{ws_id}/cancel). This signals that cancellation is in progress, not that it is complete. The worker thread may still be finishing. Clear any in-progress assistant rendering, but keep the composer disabled until the workstream emits a terminal state_change (idle in the normal cancel path, or error). stream_end only closes assistant rendering: it may already have arrived before Stop reaches an approval or tool phase, so it is not a cancellation-completion signal.

The cancelled event is not itself a history row. If cancellation accepts a partial assistant response or synthesizes tool-result receipts to close outstanding calls, those assistant/tool rows appear in /history and advance the same handoff prefix.

{"type": "cancelled"}

intent_verdict -- delivered asynchronously when the LLM judge completes its evaluation of a pending tool call. Only sent when intent validation is enabled (judge.enabled through Admin → Judge or the admin settings API). The interactive CLI instead uses --judge or [judge] enabled = true. The call_id correlates with the item in the preceding approve_request event.

{
  "type": "intent_verdict",
  "verdict_id": "f7e8d9c0b1a2",
  "call_id": "call_abc123",
  "func_name": "bash",
  "intent_summary": "Install Express.js web framework via npm",
  "risk_level": "medium",
  "confidence": 0.85,
  "recommendation": "review",
  "reasoning": "The command installs express from npm. This is a well-known package but will modify node_modules and package.json.",
  "evidence": ["Checked package.json -- express is not currently a dependency"],
  "tier": "llm",
  "judge_model": "gpt-5",
  "latency_ms": 2340
}
Field Type Description
verdict_id string Unique verdict identifier
call_id string Tool call ID (matches approve_request item)
func_name string Tool function name
intent_summary string One-sentence description of the tool call's intent
risk_level string "low", "medium", "high", or "critical"
confidence float 0.0--1.0 confidence in the assessment
recommendation string "approve", "review", or "deny"
reasoning string Evidence-based explanation
evidence list Supporting evidence (file excerpts, rule names)
tier string Always "llm" for this event
judge_model string Model that produced the verdict
latency_ms int Evaluation time in milliseconds

When intent validation is active, the approve_request event is also extended: each item in items gains a verdict field containing the heuristic verdict (same schema as above but with tier: "heuristic"), and the event gains a top-level judge_pending boolean indicating whether an LLM verdict is in flight.

Keepalive

The server sends an SSE comment every 5 seconds when no events are pending:

: keepalive

This prevents proxies and browsers from closing the connection due to inactivity.

Multi-consumer fan-out

Each SSE connection to a workstream receives its own delivery queue. Events produced by the worker thread are fanned out to all registered listener queues, so multiple consumers (browser, console proxy, SDK) can connect simultaneously and each receives every event. On reconnect the client receives either the event-ring delta after its cursor or a synthetic recovery replay. The synthetic replay includes connected, cached status, every pending approval cycle, the current state_change, and an optional in_progress_snapshot with partial content/reasoning. Conversation history stays on the REST /history endpoint.


GET /v1/api/workstreams/{ws_id}/history

Returns the tail of the reconstructed conversation without opening the workstream. The endpoint works for a live session and for a saved workstream that is not loaded in the manager. Cross-kind, tenant, and private-project visibility checks run before storage reconstruction.

Query parameter Type Default Description
limit integer 100 Tail row limit, clamped to 1--500

The response is {"ws_id": ..., "messages": [...], "cursor": ..., "handoff_token": ...} using the message shape and total-prefix contract documented in the event-stream bootstrap above. cursor is normally null; when non-null, pass it as last_event_id on the initial /events request. For a loaded workstream, pass the non-null handoff_token from the history just rendered on that same initial request. A missing, invisible, or wrong-kind workstream returns the endpoint's ordinary 404 shape.

If the durable prefix cannot be loaded, the endpoint returns:

{"error": "History temporarily unavailable"}

Status code: 503. This response is not authoritative and carries no usable handoff token. Keep any current transcript, do not open a tokenless replacement stream, and retry the history read.


GET /v1/api/events/global

Opens a Server-Sent Events stream that broadcasts state-change events across all workstreams. This is used by the tab bar to display per-workstream activity indicators.

Events:

{"type": "ws_state", "ws_id": "abc123", "state": "thinking", "persistence_state": "healthy"}
Field Type Description
ws_id string Workstream identifier
state string Current workstream state
persistence_state string Sanitized history-save state: healthy, pending, retrying, or conflict; omitted by older nodes means healthy

Possible state values:

State Description
idle No active processing
thinking Model is generating a response
running Tool execution in progress
attention Waiting for user input (approval or plan review)
error An error occurred

Fan-out pattern: Each connected client receives its own bounded queue (maxsize=1000). A dedicated fan-out thread reads from the shared global queue and copies each event to every client queue. If a client queue is full, the event is silently dropped for that client.

Keepalive: Same as /v1/api/workstreams/{ws_id}/events -- an SSE comment every 5 seconds.


GET /v1/api/workstreams

Returns a list of all active workstreams.

Response:

{
  "workstreams": [
    {"ws_id": "abc123", "name": "default", "state": "idle", "persistence_state": "healthy"},
    {"ws_id": "def456", "name": "hacker-news", "state": "thinking", "persistence_state": "retrying"}
  ]
}

Each workstream object:

Field Type Description
ws_id string Unique workstream routing identifier
name string Display name (alias if set, otherwise ws-xxxx)
state string Current state (see state values above)
persistence_state string Sanitized history-save state; defaults to healthy for older or unloaded rows

The persistence state intentionally carries no retry counts, timestamps, storage errors, commit keys, or conversation content. pending means an accepted row awaits its first durable save, retrying means automatic repair is active, and conflict requires operator intervention.


GET /v1/api/workstreams/saved

Returns a list of saved workstreams from the database, ordered by most recently updated.

Response:

{
  "workstreams": [
    {
      "ws_id": "a1b2c3d4e5f6",
      "alias": "refactor",
      "title": "JWT Authentication Refactor",
      "created": "2026-03-01 10:00:00",
      "updated": "2026-03-01 11:30:00",
      "message_count": 42
    }
  ]
}

Each saved workstream object:

Field Type Description
ws_id string Unique workstream identifier
alias string/null User-assigned short name
title string/null LLM-generated title
created string ISO timestamp of workstream creation
updated string ISO timestamp of last message
message_count int Number of messages in the workstream

GET /v1/api/skills

Returns a summary list of all available skills. This is a read-only endpoint (requires read scope) that exposes skill names and categories without revealing skill content. Useful for populating skill selectors in UIs or discovering available skills before creating a workstream.

Response:

{
  "skills": [
    {"name": "safety-guidelines", "category": "safety", "is_default": true, "origin": "manual"},
    {"name": "mcp__server__code", "category": "", "is_default": false, "origin": "mcp"}
  ]
}

Each skill summary:

Field Type Description
name string Skill name (used in skill field on workstream creation)
category string Skill category
is_default bool Whether skill is auto-applied to all sessions
origin string Skill origin: manual or mcp

Note: For full skill management (create, update, delete, view content), use the admin endpoints at GET /v1/api/admin/skills (requires admin.skills permission).


GET /v1/api/personas

Returns the enabled personas offered by the workstream-creation pickers. Authenticated for any logged-in user and deliberately gated by no persona.* permission — selecting a persona at creation is a user action, while the persona.* perms gate authoring. Display fields only; the levers (base prompt, tool set, MCP/memory toggles) stay server-side.

Response:

{
  "personas": [
    {"name": "engineer", "display_name": "Engineer", "description": "The stock interactive workstream: full tools, MCP, and memory.", "applies_to_kinds": ["interactive"], "is_default": true},
    {"name": "researcher", "display_name": "Researcher", "description": "Answers questions with evidence — reads and cites, loads tools to verify when needed.", "applies_to_kinds": ["interactive"], "is_default": false}
  ],
  "total": 2
}

Each persona summary:

Field Type Description
name string Persona slug (used in the persona field on workstream creation)
display_name string Human-readable label for pickers
description string Short description of the persona's intent
applies_to_kinds array Workstream kinds the persona applies to (interactive / coordinator)
is_default bool Whether this is the default persona for its kind

Note: For full persona management (create, edit, archive), use the admin endpoints at /v1/api/admin/personas (requires the persona.{create,read,write} permissions).


POST /v1/api/workstreams/{ws_id}/send

Sends a user message to a workstream. Spawns a daemon worker thread that calls session.send() and streams results back via the SSE channel.

Path parameters:

Parameter Type Required Description
ws_id string yes Target workstream ID

Request body:

{"message": "Explain how the server works", "attachment_ids": ["a1"], "client_send_id": "browserSend_42"}
Field Type Required Description
message string yes The user's message text
attachment_ids string[] no Staged uploads to attach (omit = auto-consume; [] = none)
client_send_id string no Opaque optimistic-UI correlation token matching [A-Za-z0-9_-]{1,128}; echoed in user_turn and history, never used for idempotency

The token correlates delivery only. Reusing the same value does not collapse or deduplicate requests: each accepted send remains a distinct history row and user_turn event. A live message_queued event carrying the token can prove server acceptance before the POST response arrives, including when that HTTP acknowledgement is lost.

Response. Every 200 body carries attached_ids and dropped_attachment_ids (empty lists when no attachments are involved):

  • {"status": "ok", ...} — a fresh turn was dispatched.
  • {"status": "queued", "priority", "msg_id", ...} — folded into the live turn's interjection queue; delivered at the next tool-result seam. DELETE .../send with the msg_id retracts it before delivery.
  • {"status": "queued", "deferred": true, ...} — parked on the deferred-send list (a command window holds the slot, or earlier deferred sends are pending) and dispatched as its own full-fidelity send afterwards; see the defer contract under POST /v1/api/command.
  • {"status": "queue_full", ...} — the send was refused with retry-shortly semantics: the live worker's interjection queue is at capacity, the deferred-send list hit its saturation bound (10 pending — the same backpressure contract), or the deferred-send drain could not be started under resource exhaustion (the message was not accepted; nothing is parked).
  • {"status": "attachments_busy", ...} — attachments can't ride a queued turn; the staged uploads survive for a retry once the worker idles.

Error responses:

Status Body Condition
400 {"error": "message is required"} Message is empty
400 {"error": "client_send_id must match ..."} Correlation token is invalid
404 {"error": "Unknown workstream"} ws_id not found (or closed mid-send)
409 {"status": "cross_user_interjection", ...} Another participant's turn is in flight

POST /v1/api/workstreams/{ws_id}/approve

Responds to a tool approval request. The SSE stream must have previously sent an approve_request event for the given workstream.

Path parameters:

Parameter Type Required Description
ws_id string yes Target workstream ID

Request body:

{
  "approved": true,
  "feedback": null,
  "always": false,
  "cycle_id": "cycle_789"
}
Field Type Required Description
approved bool yes true to approve, false to deny
feedback string/null no Optional feedback text (sent as denial reason)
always bool no If approved, remember this round's tool names for this session
cycle_id string no Resolve this exact approval round
call_id string no Resolve the round containing this tool call

When always is true and approved is true, the workstream's WebUI adds the tool names from the resolved round to its per-tool auto-approve set. It does not enable blanket approval for unrelated tools.

Use cycle_id when possible. call_id is useful for a UI organized around individual tool rows. If neither selector is supplied, the oldest unresolved round is selected for compatibility with older clients. A selector that no longer matches returns 409 with the current oldest current_cycle_id and current_call_id; the server never silently redirects a stale click to another round.

Response:

{"status": "ok", "cycle_id": "cycle_789"}

cycle_id is null if no pending round was resolved. An invalid workstream returns 404; a stale cycle_id or call_id returns 409.


POST /v1/api/command

Executes a slash command in the given workstream. Commands run on the workstream's worker slot (mutual exclusion against sends, a running compaction, and each other) — the endpoint is not unconditionally synchronous:

  • Quick commands (everything except /compact): the endpoint waits for completion, so {"status": "ok"} means the command ran. A command still running after 25 s answers {"status": "running"} — the worker keeps going, its output reaches the pane via SSE, and the post-command pane refreshes below still fire when it completes. (The bound sits under common 30 s client/proxy timeouts — the console proxy's included — so the degraded answer actually reaches bounded callers.)
  • /compact: dispatched fire-and-forget — {"status": "ok"} means the compaction started. A large context can legitimately compact for many minutes; progress streams as compaction SSE events (see the event reference) and the persisted marker row lands on completion. Do not read /history expecting the compacted transcript immediately after the response.
  • Busy refusal: if a turn or another command holds the worker slot, the command is refused with HTTP 409 {"status": "busy", "error": ...} and did not run. Retry after the current turn finishes. (The old inline endpoint executed commands unconditionally mid-turn; the 409 makes the refusal loud for callers that only check the HTTP status.)

While a command holds the slot — and afterwards, while earlier deferred sends are still waiting (the pending list is the order authority: a fresh send never overtakes a message already acknowledged) — POST .../send requests are deferred: the server answers {"status": "queued", "deferred": true, "msg_id": ...} immediately and dispatches the message as an ordinary full-fidelity send (attachments and sender identity included) in arrival order once the slot frees — it is never routed through the mid-turn interjection queue (no length cap, no cross-user rejection). The response arrives within normal round-trip time, so timeout-bounded clients (SDKs, proxies, the coordinator) need no special handling. To retract a deferred send before it dispatches, issue the same DELETE .../send with its msg_id used for queued interjections — {"status": "removed"} confirms it will not dispatch; "not_found" means it already dispatched (or is dispatching). Retracting a deferred send discards any attachments it carried; re-attach to send them again. When a deferred send dispatches, panes receive a message_dispatched event (msg_id, plus folded: true when it folded into a live turn's interjection queue rather than spawning its own turn) so queued-message UI can settle the right way.

Durability: deferred sends are node-local and in-memory (the same lifetime as the interjection queue). "queued" is at-most-once intake, not durable acceptance — if the workstream is closed or the node restarts before the window ends, the message is dropped. Anything that must survive a restart should be re-sent after confirming dispatch (the turn appears on the SSE stream / in /history).

Request body:

{"command": "/clear", "ws_id": "abc123"}
Field Type Required Description
command string yes The slash command (e.g. /clear)
ws_id string yes Target workstream ID

/clear pushes a clear_ui SSE event to instruct the client to reset its message display and re-fetch the transcript via GET .../history (there is no SSE event that carries the messages themselves). The follow-up is emitted by the command worker itself, so it fires even when the endpoint already answered {"status": "running"}.

The remote command surface deliberately rejects lifecycle helpers /new, /workstreams, /resume, and /delete; those commands are local-CLI only because their legacy implementations enumerate or mutate storage without the HTTP tenancy gates. Remote callers must use the dedicated create, open, fork (resume_ws on create), close, and delete endpoints. /rewind and /retry likewise use their path-keyed endpoints rather than /command.

Response:

{"status": "ok"}

or {"status": "running"} as above.

Error responses:

Status Body Condition
400 {"error": "Empty command"} Command is empty
404 {"error": "Unknown workstream"} ws_id not found
409 {"status": "busy", "error": ...} A turn/command holds the worker
503 {"status": "error", "error": ...} The command worker could not be started (resource exhaustion) — the command did not run; retry shortly

POST /v1/api/workstreams/{ws_id}/cancel

Cancels the workstream's current generation. Stop propagates to the primary or fallback model stream, model-backed attachment processing, parallel task-agent and foreground tool model calls, intent and output-guard judges, tracked bash subprocesses, and every approval cycle owned by that generation. Pending plan review is rejected as well. The worker preserves any assistant content already streamed and synthesizes honest cancelled tool results where needed so the saved conversation remains replayable.

The cooperative response is immediate: status: ok acknowledges the request, not completion. A running workstream emits cancelled, then transitions to idle after its worker unwinds. Depending on where Stop arrived, stream_end may have been emitted before the cancel request or may arrive while the worker is unwinding; clients use the terminal state_change, not stream_end, to become ready. An idle cancel is a harmless no-op and emits no misleading cancellation event. Detached background shells and watches are independent resources and are not stopped by this endpoint.

Force cancel: When force is true, the server releases the stuck worker slot immediately, emits stream_end/idle, and lets a successor turn start. The abandoned daemon still owns its already-started external effects until it reaches a cancellation checkpoint. Send/model generations are fenced from late history and UI publication, but quick slash-command workers do not yet have generation checkpoints and may finish an in-place mutation concurrently with a successor. Use force cancel only when cooperative cancellation has not resolved within a few seconds — it is recovery from a wedged worker, not confirmation that every in-flight external side effect was rolled back.

Path parameters:

Parameter Type Required Description
ws_id string yes Target workstream ID

Request body:

{"force": false}
Field Type Required Description
force bool no Abandon stuck worker immediately (default: false)

The body is optional. Because cancel is a recovery verb, an empty or malformed JSON body is treated as force: false rather than blocking Stop.

Response:

{
  "status": "ok",
  "dropped": {
    "was_running": true,
    "pending_approval": {"tool_names": ["bash"], "call_id": "call_abc123"},
    "queued_messages": {"count": 1, "first_preview": "follow up after the build"}
  }
}

dropped is a best-effort, credential-redacted snapshot of affected pending work. Fields are omitted when they were not observable. Coordinator sessions currently return an empty object.

Error responses:

Status Body Condition
400 {"error": "No session"} Session not initialized
404 {"error": "Unknown workstream"} ws_id not found

POST /v1/api/workstreams/new

Creates a new workstream, subject to the configured server.max_workstreams capacity.

The endpoint accepts either application/json (legacy shape) or multipart/form-data when you want to upload attachments at creation time. Multipart requests carry one meta field containing the JSON body shown below plus zero-or-more file parts; each file is validated and reserved onto the new workstream's first turn before the dispatch worker runs, so queued multimodal turns cannot lose files to racing sends. If validation fails the fresh workstream is rolled back so no published row or phantom create/close event leaks.

Request body:

{"name": "my-ws", "model": "openai", "initial_message": "Start the review"}

All fields are optional; send an empty JSON object for a defaults-only create. An absent or malformed JSON body returns 400.

Field Type Default Description
name string auto Workstream display name
model string default Model alias from the registry
auto_approve bool false Auto-approve all tool calls for this workstream
auto_approve_tools string/array "" Tool names to auto-approve even when auto_approve is false; accepts comma-separated text or an array
user_id string "" Owner override honored only for a trusted console service identity carrying the service scope; ordinary callers remain bound to their authenticated identity
resume_ws string "" Source workstream ID or alias to fork atomically into this new ID
skill string "" Skill name. Applies its system prompt and session configuration. Returns 400 if missing/disabled; ignored for a fork because the source configuration is cloned.
persona string "" Persona slug; empty selects the kind's default. A fork keeps the source persona.
judge_model string "" Optional judge model alias
initial_message string "" First user message to dispatch after publication
ws_id 32-hex string generated Caller-selected destination ID; required by the cluster multipart routing path
project_id string/null none Project to attach. A fork always inherits the source's effective project.
notify_targets string/array [] Completion-notification targets
client_type string web Client surface label (web, cli, chat, or scheduled)
parent_ws_id string/null none Owning coordinator ID for a coordinator-spawned child

Skill behavior: When skill is specified, the skill's content is injected as a system message and its session config fields (model, temperature, auto-approve, token budget, etc.) override system defaults for the new workstream.

Fork behavior (resume_ws)

Despite the compatibility field name, resume_ws does not reopen or move the source workstream. It creates a distinct destination ID and atomically clones the source's checkpoint-bounded conversation, saved session configuration, persona, effective project, and attachment references. The source remains unchanged. Use POST .../{ws_id}/open when you want to rehydrate the original ID instead.

The clone transaction rechecks source visibility, private-project membership and attachability, persona/project construction context, destination ownership and emptiness, and attachment integrity. A caller cannot use project_id to re-file or declassify the fork. Uploads cannot be combined with resume_ws; fork first, then use the ordinary attachment endpoint. Concurrent source-history writes serialize wholly before or after the clone snapshot; access, construction-context, or destination conflicts fail the whole fork rather than publishing a mixed result.

Publication and rollback

Creation first reserves the ID durably with internal state creating. That reservation is hidden from list, saved, resolve, open, and cluster-event surfaces while the session is constructed, uploads are validated, and an optional fork transaction commits. The final durable creating to idle compare-and-set happens before ws_created, audit, initial-message dispatch, or any state event. A normal pre-publication failure immediately and conditionally deletes the exact token-bearing reservation and emits no lifecycle event; if cleanup itself fails, the original HTTP error is retained and ws.create.rollback_failed is logged, leaving the row hidden rather than advertising a half-create.

Long-lived server and console processes also run hidden-reservation recovery at boot and every five minutes, independently of ordinary idle eviction. It only considers rows still in internal state='creating' and older than two hours, excluding IDs currently loaded or pending in the manager. A live remote owner protects its rows; the current process's stable node ID does not self-protect, so a restart can recover its predecessor's residue. Failure to establish service liveness, or a storage failure, deletes nothing. Eligible rows are atomically hard-deleted with their dependent records and attachment refcounts; an eligible tokenless legacy or corrupt reservation is locked, recovered, and logged as a warning. Retention pruning leaves creating rows to this path. The value is not a live WorkstreamState, and recovery neither publishes nor closes it.

Response (success):

{
  "ws_id": "ghi789",
  "name": "ws-3",
  "resumed": false,
  "message_count": 0,
  "attachment_ids": []
}
Field Type Description
ws_id string Unique ID of the new workstream
name string Assigned workstream display name
resumed bool Whether the requested source was successfully forked
message_count int Messages cloned into the destination (0 if fresh/empty)
attachment_ids string[] Attachments saved by this create request
initial_message_status string Present ONLY when the workstream was created but its initial_message could not be delivered: "queue_full" (a raced live worker's interjection queue was at capacity — resend via /send; any uploads stay staged) or "refused_closed" (the workstream was closed mid-create). Absent whenever the message was dispatched.

For compatibility, resumed: true means the requested fork completed; the source was not resumed in place.

Selected errors:

Status Condition
400 Invalid body/upload/persona/skill, attachments combined with resume_ws, or required project missing
403 Destination project attach denied
404 Fork source missing or not visible (same shape prevents an existence oracle)
409 Caller-selected ID collision, source availability/construction context changed during fork, or destination reservation was superseded
413 Upload exceeds the configured request/file cap
429 Workstream manager is at capacity; retry after capacity frees
503 Storage/factory/model configuration unavailable, or the fork transaction failed operationally
500 Unexpected create failure; response includes a correlation ID for server logs

POST /v1/api/workstreams/{ws_id}/close

Closes and removes a workstream. The last remaining workstream cannot be closed.

Path parameters:

Parameter Type Required Description
ws_id string yes Workstream ID to close

Request body:

The body must be valid JSON. If you are not supplying any optional fields, send {} — an empty / non-JSON body is rejected with a 400.

Field Type Required Description
reason string no Optional close reason persisted to workstream_config.

The reason is capped at 512 UTF-8 bytes (multibyte-safe — the cap holds for CJK and emoji payloads), and the output guard's credential-redaction pass strips secrets before the value is persisted. A non-string reason is silently coerced to empty and the close proceeds without writing the field.

Response (success):

{"status": "ok"}

Error (last workstream):

{"error": "Cannot close last workstream"}

Status code: 400

Error (conversation persistence unresolved):

{"error": "workstream has unresolved persistence"}

Status code: 409. At least one accepted live conversation row still requires idempotent persistence reconciliation. The workstream remains loaded and no history is discarded; retry the close after storage recovers.


POST /v1/api/workstreams/{ws_id}/attachments

Upload an image or text document and attach it to the caller's next user turn on this workstream.

  • Images (png/jpeg/gif/webp) are capped at 4 MiB and validated via magic-byte sniff on upload.
  • Text documents (any text/* MIME, allow-listed application MIMEs, or known text extensions) are capped at 512 KiB and must be UTF-8.
  • Per-(workstream, user) pending cap is 10 attachments.

The attachment moves through three states: pending → reserved → consumed. Reservation tokens are threaded through POST /v1/api/workstreams/{ws_id}/send so a queued multimodal turn cannot lose its file to an overlapping send.

Ownership failures are masked as 404 so non-owners cannot enumerate workstream existence.

Content-Type: multipart/form-data with a single file field.

Response (success): 200

{
  "attachment_id": "att_abc123",
  "kind": "image",
  "mime_type": "image/png",
  "size_bytes": 73240,
  "filename": "screenshot.png",
  "state": "pending"
}

Errors:

Code Meaning
400 Missing/invalid form, unsupported MIME, not UTF-8, etc.
403 Auth/scope failure
404 Workstream not found / not owned by caller
409 Pending-cap reached
413 Payload exceeds size cap

GET /v1/api/workstreams/{ws_id}/attachments

List the caller's pending (unconsumed) attachments for this workstream. Ownership failures are masked as 404.

Response: 200

{
  "attachments": [
    {
      "attachment_id": "att_abc123",
      "kind": "image",
      "mime_type": "image/png",
      "size_bytes": 73240,
      "filename": "screenshot.png",
      "state": "pending"
    }
  ]
}

GET /v1/api/workstreams/{ws_id}/attachments/{attachment_id}/content

Returns the raw bytes of an attachment with its stored Content-Type. Useful for previewing an image or replaying a document. Ownership failures are masked as 404.

Response: 200 — binary body, original Content-Type.


DELETE /v1/api/workstreams/{ws_id}/attachments/{attachment_id}

Remove a pending attachment. Consumed attachments return 404 (they are part of a committed conversation turn). Ownership failures are also masked as 404.

Response: 200

{"deleted": "att_abc123"}

POST /v1/api/workstreams/{ws_id}/delete

Permanently delete a saved workstream and all its messages from storage.

Path parameters:

Parameter Type Description
ws_id string Workstream ID

Response (success): 200

{"deleted": "a1b2c3d4"}

Response (not found): 404

{"error": "Workstream not found"}

POST /v1/api/workstreams/{ws_id}/open

Load a saved workstream into memory with its original ws_id. If the workstream is already loaded, returns immediately with already_loaded: true. An internal creating reservation is not openable and returns the ordinary not-found shape until publication completes.

Path parameters:

Parameter Type Description
ws_id string Workstream ID

Response (success): 200

{"ws_id": "a1b2c3d4", "name": "refactor"}

Response (already loaded): 200

{"ws_id": "a1b2c3d4", "name": "refactor", "already_loaded": true}

POST /v1/api/workstreams/{ws_id}/title

Set a workstream title manually. The title is stored as the workstream alias.

Path parameters:

Parameter Type Description
ws_id string Workstream ID

Request body:

{"title": "JWT Authentication Refactor"}
Field Type Required Description
title string yes New workstream title

Response (success): 200

{"status": "ok", "title": "JWT Authentication Refactor"}

Response (conflict): 409

{"error": "That name is already used by another workstream"}

POST /v1/api/workstreams/{ws_id}/refresh-title

Regenerate the workstream title via LLM based on conversation content.

Path parameters:

Parameter Type Description
ws_id string Workstream ID

Response (success): 200

{"status": "ok"}

GET /v1/api/admin/settings

List interface.* settings with their current values and sources. Requires read scope on the server.

Response: 200

{
  "settings": [
    {
      "key": "interface.close_tab_action",
      "value": "last_used",
      "source": "default",
      "type": "str",
      "description": "Determines which workstream to switch to after closing a tab."
    }
  ]
}

POST|PUT /v1/api/admin/settings/{key}

Update an interface.* setting. Only keys in the interface section are accepted; other keys return 400.

Path parameters:

Parameter Type Description
key string Setting key (e.g. interface.theme)

Request body:

{"value": "light"}
Field Type Required Description
value any yes New value

Response (success): 200

{"status": "ok", "key": "interface.theme", "value": "light"}

Error: 400 if the key is not in the interface section.


GET /v1/api/watches

List active watches on this server node. Optionally filter by workstream. Requires write scope.

Query parameters:

Parameter Type Required Description
ws_id string no Filter to watches for this workstream. If omitted, returns all watches on the node.

Response:

{
  "watches": [
    {
      "watch_id": "abc123def456...",
      "ws_id": "ws-1",
      "node_id": "host_a1b2",
      "name": "pr-review",
      "command": "gh pr view --json state",
      "interval_secs": 300.0,
      "stop_on": "data[\"state\"] == \"MERGED\"",
      "max_polls": 100,
      "poll_count": 5,
      "last_output": "{\"state\": \"OPEN\"}",
      "last_poll": "2026-03-09T12:00:00",
      "next_poll": "2026-03-09T12:05:00",
      "active": 1,
      "created": "2026-03-09T11:30:00"
    }
  ]
}

POST /v1/api/watches/{watch_id}/cancel

Cancel an active watch. Sets active=0 and clears next_poll. Requires write scope. Verifies node ownership in multi-node deployments.

Path parameters:

Parameter Type Description
watch_id string Watch ID to cancel

Response (success):

{"status": "ok", "watch_id": "abc123def456..."}

Error (not found):

{"error": "Watch not found"}

Status code: 404

Error (wrong node):

{"error": "Watch belongs to another node"}

Status code: 403


GET /v1/api/memories

List structured memories with optional filters. Requires read scope.

Query parameters:

Parameter Type Required Default Description
type string no "" Filter by memory type (user, project, feedback, reference)
scope string no "" Filter by scope (global, workstream, user)
scope_id string no "" Scope qualifier. Auto-resolved for scope=user when auth is active.
limit int no 100 Max results (capped at 200)

Response:

{
  "memories": [
    {
      "memory_id": "a1b2c3d4-e5f6-...",
      "name": "project_architecture",
      "description": "Core architecture patterns",
      "type": "project",
      "scope": "global",
      "scope_id": "",
      "content": "The project uses a hexagonal architecture...",
      "created": "2026-03-10T10:00:00",
      "updated": "2026-03-12T14:30:00"
    }
  ],
  "total": 1
}

POST /v1/api/memories

Save or upsert a structured memory. Requires write scope. Returns 201 on create, 200 on update.

Request body:

{
  "name": "deployment_process",
  "content": "Deploy via GitHub Actions. Staging auto-deploys on push to main.",
  "description": "CI/CD deployment workflow",
  "type": "project",
  "scope": "global",
  "scope_id": ""
}
Field Type Required Default Description
name string yes -- Memory name (max 256 chars)
content string yes -- Memory content (max 65536 chars)
description string no "" Short description for search ranking
type string no "project" One of: user, project, feedback, reference
scope string no "global" One of: global, workstream, user
scope_id string no "" Scope qualifier (auto-resolved for user scope)

Response (created): 201

{
  "memory_id": "a1b2c3d4-e5f6-...",
  "name": "deployment_process",
  "description": "CI/CD deployment workflow",
  "type": "project",
  "scope": "global",
  "scope_id": "",
  "content": "Deploy via GitHub Actions...",
  "created": "2026-03-14T10:00:00",
  "updated": "2026-03-14T10:00:00"
}

Error responses:

Status Condition
400 Missing name, empty content, invalid type/scope, name too long, content too long

POST /v1/api/memories/search

Search memories by query. Uses POST for the request body but is non-mutating (requires only read scope).

Request body:

{
  "query": "authentication",
  "type": "project",
  "scope": "",
  "limit": 20
}
Field Type Required Default Description
query string yes -- Search query
type string no "" Filter by type
scope string no "" Filter by scope
scope_id string no "" Filter by scope ID
limit int no 20 Max results (capped at 50)

Response:

{
  "memories": [
    {
      "memory_id": "a1b2c3d4-e5f6-...",
      "name": "auth_patterns",
      "description": "Authentication architecture",
      "type": "project",
      "scope": "global",
      "scope_id": "",
      "content": "JWT tokens with HS256...",
      "created": "2026-03-10T10:00:00",
      "updated": "2026-03-12T14:30:00"
    }
  ],
  "total": 1
}

Error: 400 with {"error": "query is required"} if query is empty.


DELETE /v1/api/memories/{name}

Delete a memory by name and scope. Requires write scope.

Path parameters:

Parameter Type Description
name string Memory name

Query parameters:

Parameter Type Required Default Description
scope string no "global" Scope of the memory
scope_id string no "" Scope qualifier

Response (success): 200

{"status": "ok", "name": "deployment_process"}

Error (not found): 404

{"error": "Memory 'deployment_process' not found"}

GET /v1/api/admin/memories (Console)

List structured memories across all scopes. Requires admin.memories permission.

Query parameters:

Parameter Type Required Default Description
type string no "" Filter by type
scope string no "" Filter by scope
scope_id string no "" Filter by scope ID
limit int no 100 Max results (capped at 200)

Response: 200 -- same schema as GET /v1/api/memories.


GET /v1/api/admin/memories/search (Console)

Search memories by query. Requires admin.memories permission.

Query parameters:

Parameter Type Required Default Description
q string yes -- Search query
type string no "" Filter by type
scope string no "" Filter by scope
scope_id string no "" Filter by scope ID
limit int no 20 Max results (capped at 50)

Response: 200 -- same schema as GET /v1/api/memories.

Error: 400 with {"error": "q is required"} if q is empty.


GET /v1/api/admin/memories/{memory_id} (Console)

Get a single memory by ID. Requires admin.memories permission.

Path parameters:

Parameter Type Description
memory_id string Memory UUID

Response (success): 200

{
  "memory_id": "a1b2c3d4-e5f6-...",
  "name": "project_architecture",
  "description": "Core architecture patterns",
  "type": "project",
  "scope": "global",
  "scope_id": "",
  "content": "The project uses...",
  "created": "2026-03-10T10:00:00",
  "updated": "2026-03-12T14:30:00"
}

Error (not found): 404

{"error": "Memory not found"}

DELETE /v1/api/admin/memories/{memory_id} (Console)

Delete a memory by ID. Records an audit event (memory.delete). Requires admin.memories permission.

Path parameters:

Parameter Type Description
memory_id string Memory UUID

Response (success): 200

{"status": "ok"}

Error (not found): 404

{"error": "Memory not found"}

GET /v1/api/admin/verdicts (Console)

List intent validation verdicts from the intent_verdicts table. This endpoint is on the console server and requires the admin.judge permission.

Query parameters:

Parameter Type Required Description
ws_id string no Filter by workstream ID
since string no ISO timestamp lower bound
until string no ISO timestamp upper bound
risk_level string no Filter by risk level (low/medium/high/critical)
limit int no Max results (default 100, max 500)
offset int no Pagination offset (default 0)

Response:

{
  "verdicts": [
    {
      "verdict_id": "a1b2c3d4e5f6",
      "ws_id": "ws-1",
      "call_id": "call_abc123",
      "func_name": "bash",
      "func_args": "{\"command\": \"npm install express\"}",
      "intent_summary": "Package installation: npm install express",
      "risk_level": "medium",
      "confidence": 0.70,
      "recommendation": "review",
      "reasoning": "Command installs a software package which may modify the environment.",
      "evidence": "[\"Matched rule: package-install\"]",
      "tier": "heuristic",
      "judge_model": "",
      "latency_ms": 0,
      "user_decision": "approved",
      "created": "2026-03-13T10:00:00"
    }
  ],
  "total": 42
}

GET /v1/api/admin/output-assessments (Console)

List output guard assessments from the output_assessments table. This endpoint is on the console server and requires the admin.judge permission.

Query parameters:

Parameter Type Required Description
ws_id string no Filter by workstream ID
risk_level string no Filter by risk level (low/medium/high)
since string no ISO timestamp lower bound
until string no ISO timestamp upper bound
limit int no Max results (default 100, max 500)
offset int no Pagination offset (default 0)

Response:

{
  "assessments": [
    {
      "assessment_id": "a1b2c3d4e5f6",
      "ws_id": "ws-1",
      "call_id": "call_abc123",
      "func_name": "bash",
      "flags": "[\"credential_leak\"]",
      "risk_level": "high",
      "annotations": "[\"API key detected (sk-proj-...)\"]",
      "output_length": 1024,
      "redacted": 1,
      "created": "2026-03-16T10:00:00"
    }
  ],
  "total": 7
}

POST /v1/api/admin/skills/{skill_id}/rescan (Console)

Re-scan a skill's content for security signals using the current scanner version. Requires the admin.skills permission.

Path parameters:

Parameter Type Description
skill_id string Skill (prompt template) ID

Response:

{
  "risk_level": "medium",
  "scan_report": "{\"composite\": 1.75, \"details\": {...}}",
  "scan_version": "1"
}

Error: 404 if skill not found.


GET /v1/api/admin/skills/discover (Console)

Search external skill registries for available skills. Requires the admin.skills permission.

Query parameters:

Parameter Type Default Description
q string "" Search query
limit int 20 Max results (1100)

Response:

{
  "skills": [
    {
      "id": "owner/repo/skill-name",
      "name": "skill-name",
      "description": "A skill description",
      "author": "Author Name",
      "source": "skills.sh",
      "source_url": "https://github.com/owner/repo",
      "install_count": 42,
      "tags": ["coding", "review"],
      "installed": false
    }
  ]
}

Error: 502 if the registry is unreachable.


POST /v1/api/admin/skills/install (Console)

Install a skill from an external source (skills.sh registry or GitHub). Requires the admin.skills permission.

Request body:

{
  "source": "github",
  "url": "https://github.com/owner/skill-repo"
}

Or for skills.sh:

{
  "source": "skills.sh",
  "skill_id": "owner/skill-name"
}

Response: Same as GET /v1/api/admin/skills/{skill_id} — the created skill object.

Errors: 400 invalid source or missing fields, 404 SKILL.md not found, 409 skill already installed (duplicate source_url or name), 502 source unreachable.


GET /v1/api/admin/settings (Console)

List all settings with their effective values, defaults, and metadata. Requires the admin.settings permission.

Response: 200

{
  "settings": [
    {
      "key": "model.temperature",
      "value": 0.7,
      "source": "storage",
      "type": "float",
      "description": "Sampling temperature",
      "section": "model",
      "is_secret": false,
      "node_id": "",
      "changed_by": "admin",
      "updated": "2026-03-14T10:00:00",
      "restart_required": false
    }
  ]
}

GET /v1/api/admin/settings/schema (Console)

Return the full registry catalog (all defined settings with metadata). Requires the admin.settings permission. Useful for building dynamic admin UIs.

Response: 200

{
  "schema": [
    {
      "key": "model.temperature",
      "type": "float",
      "default": 0.5,
      "description": "Sampling temperature",
      "section": "model",
      "is_secret": false,
      "min_value": 0.0,
      "max_value": 2.0,
      "choices": null,
      "restart_required": false
    }
  ]
}

PUT /v1/api/admin/settings/{key} (Console)

Update a setting. Requires the admin.settings permission. The value is validated against the registry definition (type coercion, range checks, choices). Secret settings (is_secret=true) return 403.

Path parameters:

Parameter Type Description
key string Dotted setting key (e.g. model.temperature)

Request body:

{
  "value": 0.7,
  "node_id": ""
}
Field Type Required Default Description
value any yes -- New value (type-coerced against registry)
node_id string no "" Node ID for per-node override

Response (success): 200

{
  "key": "model.temperature",
  "value": 0.7,
  "source": "storage",
  "type": "float",
  "description": "Sampling temperature",
  "section": "model",
  "is_secret": false,
  "node_id": "",
  "changed_by": "admin",
  "updated": "",
  "restart_required": false
}

Errors:

Status Condition
400 Unknown key, invalid value, type mismatch, out of range, missing value field
403 Secret setting (must use config.toml or env)

DELETE /v1/api/admin/settings/{key} (Console)

Reset a setting to its registry default by removing it from storage. Requires the admin.settings permission.

Path parameters:

Parameter Type Description
key string Dotted setting key

Query parameters:

Parameter Type Required Default Description
node_id string no "" Node ID (empty = global)

Response (success): 200

{"status": "ok", "key": "model.temperature", "default": 0.5}

Response (not found): 404

{"error": "Setting 'model.temperature' has no stored value"}

MCP Servers

Method Path Description
GET /v1/api/admin/mcp-servers List all MCP server definitions with live node status. Query: ?reveal=true to show env/header secrets.
POST /v1/api/admin/mcp-servers Create an MCP server definition. Body: {name, transport, command?, args?, url?, headers?, env?, auto_approve?, enabled?}
GET /v1/api/admin/mcp-servers/{server_id} Get a single MCP server with per-node connection status.
PUT /v1/api/admin/mcp-servers/{server_id} Update an MCP server definition. Partial updates supported.
DELETE /v1/api/admin/mcp-servers/{server_id} Delete an MCP server definition.
POST /v1/api/admin/mcp-servers/reload Tell all cluster nodes to re-read the mcp_servers DB table and reconcile (add new, remove stale, reconnect changed).
POST /v1/api/admin/mcp-servers/import Import servers from a pasted JSON config. Body: {config: {mcpServers: {...}}}. Skips existing names.

Permission: admin.mcp

Secrets (env, headers fields) are masked with *** by default. Use ?reveal=true on GET endpoints to see actual values.


MCP Registry

Search Registry

GET /v1/api/admin/mcp-registry/search

Search the official MCP Registry for available servers. Permission: admin.mcp.

Query parameters:

Parameter Type Default Description
search string "" Search query. Empty returns a browsable listing.
limit integer 20 Results per page (max 100).
cursor string Opaque cursor for pagination.

Response: 200

{
  "servers": [
    {
      "name": "io.example/mcp-server",
      "description": "...",
      "title": "Example Server",
      "version": "1.0.0",
      "website_url": "https://example.com",
      "repository": {"url": "...", "source": "github"},
      "icons": [],
      "remotes": [{"type": "streamable-http", "url": "...", "headers": [...], "variables": {...}}],
      "packages": [{"registry_type": "npm", "identifier": "@example/server", "version": "1.0.0", "transport_type": "stdio", "environment_variables": [...]}],
      "meta": {"status": "active", "is_latest": true},
      "installed": false,
      "installed_server_id": "",
      "installed_version": "",
      "update_available": false
    }
  ],
  "total": 100,
  "next_cursor": "abc123"
}

Errors: 502 (registry unreachable).

Install from Registry

POST /v1/api/admin/mcp-registry/install

Install an MCP server from the registry. Auto-reloads all cluster nodes. Permission: admin.mcp.

Request body:

{
  "registry_name": "io.example/mcp-server",
  "source": "remote",
  "index": 0,
  "name": "",
  "variables": {},
  "env": {"API_KEY": "sk-..."},
  "headers": {"Authorization": "Bearer ..."}
}
Field Type Required Description
registry_name string yes Server name from registry search results.
source string yes "remote" (streamable-http) or "package" (npm/pypi).
index integer no (default 0) Which remote or package entry to use.
name string no Custom server name. Auto-derived from registry name if empty.
variables object no Values for URL template {var} placeholders.
env object no Environment variable values for package servers.
headers object no Header values for remote servers.

Response: Same as POST /v1/api/admin/mcp-servers (McpServerDetail).

Errors: 400 (validation), 404 (not in registry), 409 (already installed or name collision), 502 (registry unreachable).


OPTIONS (any path)

Handles CORS preflight requests.

Response headers:

Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type

Status code: 200 with an empty body.


Error Handling

Condition Behavior
Malformed, absent, or non-object body on an endpoint that requires a JSON object 400; cancel is the deliberate recovery-verb exception and treats it as force: false
Unknown ws_id 404 with {"error": "Unknown workstream"}
Unknown path (GET or POST) 404 with plain-text body Not found
Empty message on /v1/api/workstreams/{ws_id}/send 400 with {"error": "Empty message"}
Empty command on /v1/api/command 400 with {"error": "Empty command"}
Rate limit exceeded 429 with Retry-After header (see below)

429 Too Many Requests

Returned when the per-IP rate limiter rejects a request. /health and /metrics are exempt.

Response headers:

Retry-After: 2

Response body:

{"error": "Rate limit exceeded", "retry_after": 2}
Field Type Description
error string "Rate limit exceeded"
retry_after number Seconds until the client should retry

SSE Reconnection

The embedded JavaScript client implements exponential backoff for SSE reconnection:

Parameter Value
Base delay 1 second
Backoff multiplier 2x on each consecutive failure
Maximum delay 30 seconds
Reset Delay resets to 1 second on first success

Per-workstream events carry monotonic SSE IDs and are retained in a bounded ring. Native Last-Event-ID and the ?last_event_id=N query fallback both resume after the last applied event. If the ring covers the gap, only missing events are replayed. If it does not, the server emits:

{
  "type": "replay_truncated",
  "ws_id": "abc123",
  "lost_count": 4,
  "earliest_available_id": 91
}

The clients then refetch /history, adopt its optional resume cursor, and reconnect; an in-progress snapshot covers partial text on the synthetic path. This REST snapshot plus cursor/delta split prevents both missing turns and double-rendering across refreshes, ring eviction, and process restart. The global state stream has its own snapshot/replay floor rather than conversation history.

history_resync is a stronger repair signal than replay_truncated: it means the one-shot token no longer names the accepted row prefix used for the rendered history. The server closes that stream. Clients retain the current transcript, fetch and render /history again, then reconnect with the new cursor/token pair; numeric replay alone is insufficient. If the repair read returns 503, clients must keep the repair latched and must not open a cursorless or tokenless stream.


Observability

GET /health

Returns server health status. Always returns 200 OK while the server process is running. "status": "degraded" indicates the server is up but the LLM backend is unreachable. Suitable for load-balancer health checks and Kubernetes liveness probes.

Response: application/json

{
  "status": "ok",
  "version": "0.4.0",
  "node_id": "worker-01_a3f2",
  "uptime_seconds": 3614.72,
  "model": "llama-3.1-70b-instruct",
  "workstreams": {
    "total": 2,
    "idle": 1,
    "thinking": 1,
    "running": 0,
    "attention": 0,
    "error": 0
  },
  "backend": {
    "status": "up",
    "circuit_state": "closed"
  }
}
Field Type Description
status string "ok" or "degraded" (degraded when backend unreachable)
version string turnstone server version
node_id string Server-generated node identity ({hostname}_{4hex})
uptime_seconds number Seconds since the server process started
model string Model name detected or configured at startup
workstreams.total integer Total active workstreams
workstreams.idle integer Workstreams waiting for user input
workstreams.thinking integer Workstreams with LLM currently streaming
workstreams.running integer Workstreams executing tools
workstreams.attention integer Workstreams blocked on approval or plan review
workstreams.error integer Workstreams in error state
backend.status string "up" or "down" — LLM backend reachability
backend.circuit_state string "closed", "open", or "half_open"

GET /metrics

Returns operational metrics in Prometheus text exposition format v0.0.4. Compatible with Prometheus scrape_configs, VictoriaMetrics, Grafana Agent, and any other OpenMetrics-compatible collector.

Response: text/plain; version=0.0.4; charset=utf-8

Prometheus scrape config example

scrape_configs:
  - job_name: turnstone
    static_configs:
      - targets: ["localhost:8080"]
    metrics_path: /metrics

Metrics reference

Metric Type Labels Description
turnstone_build_info gauge version, model Always 1; carries version/model as labels
turnstone_uptime_seconds gauge Seconds since server start
turnstone_workstreams_active_total gauge Number of active workstreams
turnstone_workstreams_by_state gauge state Workstream count per state (idle, thinking, running, attention, error)
turnstone_http_requests_total counter method, endpoint, status_code Total HTTP requests handled
turnstone_http_request_duration_seconds histogram method, endpoint Request latency distribution (11 buckets: 5ms10s)
turnstone_messages_sent_total counter User messages dispatched to the AI
turnstone_tokens_total counter type Tokens consumed (type="prompt" or type="completion")
turnstone_tool_calls_total counter tool Tool executions by name (e.g. tool="bash")
turnstone_errors_total counter Errors reported by workstreams
turnstone_context_window_used_ratio gauge Last known fraction of context window in use (0.01.0)
turnstone_sse_connections_active gauge Number of open SSE connections
turnstone_ratelimit_rejected_total counter Requests rejected by the per-IP rate limiter
turnstone_backend_up gauge LLM backend reachability (1 = up, 0 = down)
turnstone_circuit_state gauge Circuit breaker state (0 = closed, 1 = open, 2 = half_open)
turnstone_workstreams_evicted_total counter Workstreams auto-evicted when at capacity

Example output

# HELP turnstone_build_info Server version and model info
# TYPE turnstone_build_info gauge
turnstone_build_info{version="0.2.0",model="llama-3.1-70b-instruct"} 1
# HELP turnstone_uptime_seconds Server uptime in seconds
# TYPE turnstone_uptime_seconds gauge
turnstone_uptime_seconds 3614.72
# HELP turnstone_workstreams_active_total Number of active workstreams
# TYPE turnstone_workstreams_active_total gauge
turnstone_workstreams_active_total 1
# HELP turnstone_http_requests_total Total HTTP requests handled
# TYPE turnstone_http_requests_total counter
turnstone_http_requests_total{method="GET",endpoint="/health",status_code="200"} 42
turnstone_http_requests_total{method="GET",endpoint="/metrics",status_code="200"} 7
turnstone_http_requests_total{method="POST",endpoint="/v1/api/workstreams/{ws_id}/send",status_code="200"} 18
# HELP turnstone_tokens_total Total tokens consumed
# TYPE turnstone_tokens_total counter
turnstone_tokens_total{type="prompt"} 84320
turnstone_tokens_total{type="completion"} 12150
# HELP turnstone_tool_calls_total Total tool executions by name
# TYPE turnstone_tool_calls_total counter
turnstone_tool_calls_total{tool="bash"} 7
turnstone_tool_calls_total{tool="read_file"} 3

Console Routing Proxy Endpoints

These endpoints are served by the console (turnstone-console) and proxy requests to the correct server node via rendezvous (HRW) hashing over the live service registry. In multi-node deployments, clients (SDK, channel gateway) talk to the console instead of individual server nodes.

POST /v1/api/route/workstreams/new

Create a workstream through the console routing layer. The JSON body accepts the ordinary create fields plus target_node:

Field Routing behavior
ws_id Optional 32-hex destination. When present, it is preserved and used as the rendezvous key, including on a fork. A 503 never replaces a caller-selected ID.
resume_ws Optional source ID or saved alias for an atomic fork. The console resolves aliases to the canonical source ID before routing and forwards that canonical value. When no destination ws_id is supplied, the source is the placement key.
target_node Optional node ID hint. When neither ws_id nor resume_ws selects placement, the console generates a destination whose rendezvous owner is this live node.

Without any placement field, the console generates a destination ID and routes it by rendezvous. Multipart callers must pre-allocate the destination and put the same 32-hex value in both ?ws_id=<32-hex> and the multipart meta.ws_id field. The query value selects the target node; the console buffers the body, parses only meta to require the same destination ID, then forwards the original bytes and boundary unchanged. The node uses meta.ws_id as the destination identity.

The response extends the node create response with three required fields: node_url, authoritative node_id, and routing_strategy. routing_strategy is rendezvous for generated, explicit JSON, and multipart destination IDs; target_node when the console generated an ID for a requested node; or resume only when an atomic fork was placed by its canonical source ID. The node-returned destination ws_id is authoritative for the response, storage binding lookup, and audit record; the fork source is never reported as the created destination.

The JSON body must be an object. ws_id, resume_ws, and target_node must be strings when supplied; malformed placement fields return 400. A missing fork source returns the same generic 404 as other missing workstreams. If a node returns 200 without an object containing a valid destination ws_id, the console returns a bounded 502 instead of exposing or trusting the malformed payload.

GET /v1/api/route/workstreams/{ws_id}/live

Probe the rendezvous-selected owner without opening or rehydrating the workstream. The console asks that node's manager-authoritative active list and returns only:

{"ws_id": "abc123", "live": true}

Missing, unloaded, still-creating, and caller-invisible workstreams all produce live: false. Routing, upstream, and authorization uncertainty returns an error instead of a false miss, so callers can preserve an existing route.

POST /v1/api/route/workstreams/{ws_id}/send

Proxy a message to the workstream's assigned server node. DELETE on the same path dequeues a queued send.

POST /v1/api/route/workstreams/{ws_id}/approve

Proxy an approval response, including optional cycle_id / call_id, to the workstream's assigned server node.

POST /v1/api/route/workstreams/{ws_id}/cancel

Cancel generation on a workstream. The request and response have the same force / dropped shape as the node endpoint.

POST /v1/api/route/command

Send a conversation-local slash command. This legacy route still takes ws_id in the JSON body.

POST /v1/api/route/workstreams/{ws_id}/{rewind|retry}

Proxy a dedicated conversation-modification request.

POST /v1/api/route/workstreams/{ws_id}/close

Close a workstream.

The console also exposes path-keyed routed attachment endpoints and POST /v1/api/route/workstreams/delete for coordinator-driven hard deletion.

GET /v1/api/route?ws_id=X

Look up which server node owns a workstream. Returns {"node_url": "...", "node_id": "..."}. Used by channel adapters to open direct SSE connections to the correct server node.

GET /metrics (Console)

Prometheus metrics for the console routing layer. Includes: turnstone_router_requests_total, turnstone_router_request_duration_seconds, turnstone_router_membership_size, turnstone_router_refresh_total.