Skip to content

Latest commit

 

History

History
2246 lines (1665 loc) · 69.5 KB

File metadata and controls

2246 lines (1665 loc) · 69.5 KB

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 immediately on connect.
{
  "type": "connected",
  "model": "kappa_20b_131k",
  "model_alias": "default",
  "skip_permissions": false
}

skip_permissions reflects the workstream's current auto-approve state. It is true if the server was started with --skip-permissions or if the user chose "Always approve" via the approval prompt during the session.

  1. history -- replays the full conversation history so the client can rebuild its UI.
{
  "type": "history",
  "messages": [
    {"role": "user", "content": "Hello"},
    {"role": "assistant", "content": "Hi there!", "tool_calls": null},
    {"role": "tool", "content": "..."}
  ]
}

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 initial connected and history frames, 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 phase (history + pending), 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.

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

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."}

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 all displayed messages (sent after /clear or /new commands).

{"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 — wait for stream_end before transitioning to a ready state. The client should clear any in-progress assistant rendering but not re-enable the send button until stream_end arrives.

{"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 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 the kind-specific replay (connected + status + history + pending approval / plan for interactive; connected + status + pending for coord) followed by a state_change carrying the current worker state and an optional in_progress_snapshot carrying any partial content / reasoning buffered for the in-progress turn — so a mid-stream refresh restores both the busy-mode UI and the partial assistant text without waiting for the response to complete.


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 (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"}
Field Type Required Description
message string yes The user's message text

Response (success):

{"status": "ok"}

Response (busy): Returned if the workstream's worker thread is still alive from a previous request. Also pushes a busy_error event to the SSE stream.

{"status": "busy"}

Error responses:

Status Body Condition
400 {"error": "Empty message"} Message is empty
404 {"error": "Unknown workstream"} ws_id not found

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}
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 true and approved, enables auto-approve

When always is true and approved is true, the workstream's WebUI instance sets auto_approve = True, causing all subsequent tool calls to be automatically approved without prompting.

Response:

{"status": "ok"}

Error: 404 with {"error": "Unknown workstream"} if ws_id is invalid.


POST /v1/api/command

Executes a slash command in the given workstream.

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

If the command is /clear or /new, the server pushes a clear_ui SSE event to instruct the client to reset its message display. If the command is /resume, the server pushes clear_ui followed by a history event containing the resumed session's messages.

Response:

{"status": "ok"}

Error responses:

Status Body Condition
400 {"error": "Empty command"} Command is empty
404 {"error": "Unknown workstream"} ws_id not found

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

Cancels the active generation in a workstream. Sets a cooperative cancellation flag that is checked at multiple points in the generation loop (per streaming chunk, before tool execution, inside bash commands). Also closes the underlying HTTP stream to the LLM provider, unblocking any pending read immediately. The session transitions to idle state and preserves any partial content already streamed.

If the workstream is waiting for tool approval or plan review, the pending prompt is automatically denied/rejected to unblock the worker thread.

Calling this endpoint when the workstream is already idle is a harmless no-op.

Force cancel: When force is true, the server abandons the stuck worker thread immediately and transitions the workstream to idle. The abandoned thread continues to wind down in the background (killing any running subprocesses and exiting at the next cancellation checkpoint). During this wind-down it may emit a final stream_end event which the server suppresses for the orphaned thread. Use force cancel when cooperative cancel has not resolved within a few seconds — the web UI offers this as a "Force Stop" button automatically.

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)

Response:

{"status": "ok"}

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. The server supports up to 10 concurrent workstreams.

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 orphan rows leak.

Request body:

{"name": "my-ws", "model": "openai"}

All fields are optional. The body can be empty or an empty JSON object.

Field Type Default Description
name string auto Workstream display name
model string default Model alias from the registry ([models.*])
auto_approve bool false Auto-approve all tool calls for this workstream
resume_ws string "" Workstream ID to resume atomically during creation (empty = fresh)
skill string "" Skill name. Applies content (system prompt), model, temperature, reasoning effort, max tokens, auto-approve policy, token budget, and other session config from the skill. Returns 400 if not found or disabled. Ignored when resume_ws is set (resumed sessions restore their own skill).
persona string "" Persona slug. Resolved and snapshotted into the workstream at creation; empty selects the kind's default.
judge_model string "" Optional model alias for the judge (overrides default judge model for this workstream)

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.

Response (success):

{"ws_id": "ghi789", "name": "ws-3", "resumed": false, "message_count": 0}
Field Type Description
ws_id string Unique ID of the new workstream
name string Auto-generated workstream name
resumed bool Whether a previous session was successfully resumed
message_count int Number of messages in the resumed session (0 if fresh)
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.

Error (limit reached):

{"error": "Maximum of 10 workstreams reached"}

Status code: 400


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.

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 or unparseable JSON body Treated as an empty dict {}; missing fields use defaults
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

On reconnect, the server replays the full conversation history via the history event, so the client can rebuild its UI state without data loss. The same reconnection strategy applies to both the per-workstream SSE stream (/v1/api/workstreams/{ws_id}/events) and the global state stream (/v1/api/events/global).


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 via rendezvous routing. The console generates the ws_id, routes to the rendezvous-selected node, and includes node_url in the response for direct SSE connections.

POST /v1/api/route/send

Proxy a message to the workstream's assigned server node.

POST /v1/api/route/approve

Proxy an approval response to the workstream's assigned server node.

POST /v1/api/route/cancel

Cancel generation on a workstream.

POST /v1/api/route/command

Send a slash command to a workstream.

POST /v1/api/route/plan

Send plan review feedback to a workstream.

POST /v1/api/route/workstreams/close

Close a workstream.

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.