90 KiB
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_serveron turnstone-server,turnstone_auth_consoleon 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
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.
- 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": "..."}
],
"cursor": null
}
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.
Each message in the messages array has:
| Field | Type | Description |
|---|---|---|
role |
string | "user", "assistant", or "tool" |
content |
string or null | Text content of the message |
tool_calls |
array or null | Present only on assistant messages with calls |
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:
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 -- final output from a completed tool execution. The call_id matches the corresponding tool_info/approve_request item and any preceding tool_output_chunk events. For bash tools, this 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}
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"}
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.
{"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": ...} using the
message shape documented in the event-stream bootstrap above. cursor is
normally null; when non-null, open /events?last_event_id=<cursor> so the
ring replays the deliberately trimmed in-progress tail. A missing, invisible,
or wrong-kind workstream returns the endpoint's ordinary 404 shape.
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"}
| Field | Type | Description |
|---|---|---|
ws_id |
string | Workstream identifier |
state |
string | Current workstream state |
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"},
{"ws_id": "def456", "name": "hacker-news", "state": "thinking"}
]
}
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) |
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(requiresadmin.skillspermission).
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 thepersona.{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"]}
| Field | Type | Required | Description |
|---|---|---|---|
message |
string | yes | The user's message text |
attachment_ids |
string[] | no | Staged uploads to attach (omit = auto-consume; [] = none) |
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 .../sendwith themsg_idretracts 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 underPOST /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 |
| 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 ascompactionSSE events (see the event reference) and the persisted marker row lands on completion. Do not read/historyexpecting 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
skillis 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
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 (1–100) |
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.
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: 5ms–10s) |
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.0–1.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.