Generated from
internal/server/server.goroute table on 2026-06-28. Keep in sync withregisterRoutes.
Status: Implemented (server: internal/server/, client: internal/client/)
This document specifies the HTTP REST + SSE API for the Toasters server. Every documented endpoint corresponds to a real mux.HandleFunc(...) line in registerRoutes (internal/server/server.go), and every registered route is documented here. The server exposes this API; RemoteClient (internal/client/) consumes it as a drop-in replacement for the in-process LocalService.
The authoritative sources, if this document ever drifts:
internal/server/server.go—registerRoutes(the route table)internal/server/handlers.go— handler implementations (params, bodies)internal/server/types.go— wire request/response types and SSE payload typesinternal/server/helpers.go—mapServiceError(error → HTTP status)internal/service/events.go— SSE event types and payloadsinternal/server/middleware.go+internal/auth/— auth and middleware
- General Conventions
- Authentication
- Error Handling
- Pagination
- Async Operations
- Endpoints: Operator
- Endpoints: Skills
- Endpoints: Graphs
- Endpoints: Jobs
- Endpoints: Tasks
- Endpoints: Sessions
- Endpoints: System
- SSE Event Stream
- Middleware
| Convention | Value |
|---|---|
| Default bind address | 127.0.0.1:8421 (loopback only; --addr to change) |
| Base path | /api/v1 |
| Content-Type (request) | application/json for all request bodies |
| Content-Type (response) | application/json, except 204 No Content and SSE |
| Content-Type (SSE) | text/event-stream |
| Character encoding | UTF-8 |
| Date format | RFC 3339 (2026-06-28T12:00:00Z) |
| ID format | Opaque strings (UUID v4 or slugified names) |
| HTTP method routing | Go 1.22+ net/http.ServeMux with METHOD /path/{param} patterns |
| Path parameters | {name} syntax via http.Request.PathValue() |
| Query parameters | snake_case (?status=..., ?limit=..., ?offset=..., ?all=true) |
- Request bodies must be valid JSON. Malformed JSON returns
400 Bad Requestwith codebad_request. - Request bodies are capped at 1 MiB (
http.MaxBytesReader); oversized bodies fail decoding. - Unknown JSON fields are silently ignored (forward compatibility).
- Path parameters are always required; a missing parameter results in a
404from the router.
- Successful responses return JSON, except
204 No Contentand the SSE stream. 201 Createdresponses include aLocationheader for the created resource where applicable.202 Acceptedresponses return an async envelope (see Async Operations).- Fields tagged
omitemptyare omitted when empty/zero. - Filesystem paths are never leaked:
WorkspaceDirand similar fields are taggedjson:"-"on service DTOs.
Implemented by authMiddleware (internal/server/middleware.go) and the token loader in internal/auth/token.go.
- Scheme: Bearer token in the
Authorizationheader:Authorization: Bearer <token>. - Token source:
~/.config/toasters/server.token(file mode0600). The token is generated/loaded on server start. - Comparison: constant-time (
crypto/subtle.ConstantTimeCompare) to prevent timing attacks. - Exempt route:
GET /api/v1/healthis always reachable without a token (supports liveness probes). - Disabled mode:
toasters serve --no-authruns with auth disabled (every request passes through). Dev-only. The server refuses to start with--no-authon a non-loopback bind address, and warns when binding to a non-loopback address even with auth on (the token is sniffable over plain HTTP). - Failure: a missing or invalid token on a protected route returns
401 Unauthorizedwith codeunauthorizedand messageinvalid or missing bearer token.
CORS is restricted to localhost origins (localhost, 127.0.0.1, ::1). Requests with no Origin header (curl, same-origin, SSE) pass through unconditionally.
All error responses use a single envelope:
{
"error": {
"code": "not_found",
"message": "human-readable description"
}
}Service-layer errors map to HTTP status codes via mapServiceError (internal/server/helpers.go), using errors.Is against package sentinels in internal/service/errors.go (no string matching, except one legacy invalid provider ID substring fallback originating in internal/config).
| Service sentinel | HTTP status | code |
|---|---|---|
service.ErrNotFound |
404 Not Found |
not_found |
service.ErrConflict |
409 Conflict |
conflict |
service.ErrInvalid |
422 Unprocessable Entity |
unprocessable_entity |
service.ErrBusy |
429 Too Many Requests |
too_many_requests |
service.ErrUnavailable |
503 Service Unavailable |
service_unavailable |
| (any other error) | 500 Internal Server Error |
internal_error |
Additional codes produced directly by handlers/middleware (not via mapServiceError):
| Status | code |
When |
|---|---|---|
400 Bad Request |
bad_request |
validation failure, missing required field, malformed JSON, invalid query param |
401 Unauthorized |
unauthorized |
missing/invalid bearer token |
429 Too Many Requests |
too_many_requests |
SSE connection limit (max 10) exceeded |
502 Bad Gateway |
provider_error |
GET /providers/{id}/models when the provider is unreachable/misconfigured |
500 Internal Server Error |
internal_error |
SSE flushing not supported by the response writer |
Notes:
500responses log the real error server-side and return a genericinternal server errormessage; all other error messages are sanitized viaservice.SanitizeErrorMessagebefore being returned.- When an error maps to
too_many_requestsonPOST /skills/generate, aRetry-After: 5header is set.
List endpoints that paginate accept two query parameters (parsePagination in helpers.go):
| Param | Type | Default | Constraints |
|---|---|---|---|
limit |
int | 50 |
0–200 inclusive |
offset |
int | 0 |
>= 0 |
Out-of-range values return 400 bad_request. Paginated responses use the envelope:
{
"items": [ ... ],
"total": 123
}total is the full unpaginated count; items is the current page. Some list endpoints (operator history, blockers, models, MCP servers, catalog) return all items with total = len(items) and do not slice by limit/offset.
Long-running operations return 202 Accepted immediately with an operation handle. Completion is delivered later over the SSE stream as operation.completed / operation.failed events, correlated by operation_id.
{ "operation_id": "op-abc123" }POST /api/v1/operator/messages is a special async case: it returns a turn handle instead, correlated to operator.text / operator.done events by turn_id:
{ "turn_id": "turn-abc123" }Send a user message to the operator. Processing is asynchronous; text streams back over SSE (operator.text, operator.done) keyed by the returned turn_id.
- Request body:
{ "message": "string (required, non-empty, <= 100000 bytes)" } - Response:
202 Accepted{ "turn_id": "string" } - Status codes:
202,400(empty or too-long message),429(operator busy →service.ErrBusy),503.
Answer a pending blocker (an ask_user request raised by the operator or a graph node via rhizome.Interrupt). Unblocks the waiting caller.
- Path params:
requestId— therequest_idfrom theblocker.addedevent /GET /operator/blockers. - Request body:
{ "response": "string (required, non-empty, <= 50000 bytes)" } - Response:
204 No Content - Status codes:
204,400(missingrequestId, empty/too-long response),404(no such pending request).
Current operator state.
- Response:
200 OK{ "state": "string", "current_turn_id": "string", "model_name": "string", "endpoint": "string" }
Full operator chat transcript.
- Response:
200 OK—PaginatedResponse<ChatEntry>(all entries;total = len(items)).{ "items": [ { "message": { "role": "string", "content": "string", "tool_calls": [ { "id": "string", "name": "string", "arguments": { } } ], "tool_call_id": "string" }, "timestamp": "2026-06-28T12:00:00Z", "reasoning": "string", "claude_meta": "string" } ], "total": 0 }
List currently pending blockers (ask_user requests awaiting a human response).
- Response:
200 OK—PaginatedResponse<Blocker>(all blockers;total = len(items)).{ "items": [ { "request_id": "string", "source": "string (empty=operator, e.g. \"graph:investigate\")", "job_id": "string", "task_id": "string", "questions": [ { "question": "string", "options": ["string"] } ], "created_at": "2026-06-28T12:00:00Z" } ], "total": 0 }
Set the provider and model the operator runs on. (Registered in the System block of the route table, but operates on operator config.)
- Request body:
{ "provider_id": "string", "model": "string" } - Response:
200 OK{ "status": "ok" } - Status codes:
200,400,404,422.
List skill definitions.
- Query params:
limit,offset(paginated). - Response:
200 OK—PaginatedResponse<Skill>.{ "items": [ { "id": "string", "name": "string", "description": "string", "tools": ["string"], "prompt": "string", "source": "string", "created_at": "2026-06-28T12:00:00Z", "updated_at": "2026-06-28T12:00:00Z" } ], "total": 0 }
Get a single skill by ID.
- Path params:
id. - Response:
200 OK— aSkillobject (shape as above). - Status codes:
200,404.
Create a new (empty) skill with the given name.
- Request body:
{ "name": "string (required, non-empty)" } - Response:
201 Created— the createdSkill;Location: /api/v1/skills/{id}header set. - Status codes:
201,400,409(name conflict),422.
Delete a skill.
- Path params:
id. - Response:
204 No Content. - Status codes:
204,404.
Generate a skill from a natural-language prompt (async, LLM-backed).
- Request body:
{ "prompt": "string (required, non-empty, <= 10000 bytes)" } - Response:
202 Accepted—{ "operation_id": "string" }. Result arrives viaoperation.completed/operation.failedSSE events. - Status codes:
202,400,429(setsRetry-After: 5),503.
Graphs are declarative rhizome graph definitions. They are read-only over the API.
List graph definitions.
- Query params:
limit,offset(paginated). - Response:
200 OK—PaginatedResponse<GraphDefinition>.{ "items": [ { "id": "string", "name": "string", "description": "string", "tags": ["string"], "entry": "string", "exit": "string", "nodes": ["string"], "edges": [ { "from": "string", "to": "string", "kind": "string", "label": "string" } ] } ], "total": 0 }
Get a single graph definition.
- Path params:
id. - Response:
200 OK— aGraphDefinition(shape as above). - Status codes:
200,404.
List jobs, with optional filtering.
- Query params:
all=true— return every job (bypasses pagination and filters;total = len(items)).status— one of:pending,setting_up,decomposing,active,paused,completed,failed,cancelled. Invalid values return400.type— free-form job type string.limit,offset— pagination (ignored whenall=true).
- Response:
200 OK—PaginatedResponse<Job>.({ "items": [ { "id": "string", "title": "string", "description": "string", "type": "string", "status": "string", "created_at": "2026-06-28T12:00:00Z", "updated_at": "2026-06-28T12:00:00Z", "metadata": { } } ], "total": 0 }totalis computed by re-running the filter unpaginated.)
Get a job with its tasks and progress reports.
- Path params:
id. - Response:
200 OK—JobDetail:{ "job": { /* Job, as above */ }, "tasks": [ { "id": "string", "job_id": "string", "title": "string", "status": "string", "worker_id": "string", "graph_id": "string", "parent_id": "string", "sort_order": 0, "created_at": "2026-06-28T12:00:00Z", "updated_at": "2026-06-28T12:00:00Z", "summary": "string", "result_summary": "string", "recommendations": "string", "metadata": { } } ], "progress": [ { "id": 0, "job_id": "string", "task_id": "string", "worker_id": "string", "status": "string", "message": "string", "created_at": "2026-06-28T12:00:00Z" } ] } - Status codes:
200,404.
Cancel a job.
- Path params:
id. - Response:
204 No Content. - Status codes:
204,404,409/422(invalid state).
Retry a failed task.
- Path params:
id. - Response:
204 No Content. - Status codes:
204,404,409/422(task not in a retryable state).
Sessions are worker (and operator) LLM conversation runs.
List session snapshots.
- Query params:
limit,offset(paginated). - Response:
200 OK—PaginatedResponse<SessionSnapshot>.{ "items": [ { "id": "string", "worker_id": "string", "job_id": "string", "task_id": "string", "status": "string", "model": "string", "provider": "string", "start_time": "2026-06-28T12:00:00Z", "tokens_in": 0, "tokens_out": 0 } ], "total": 0 }
Get a session with its prompt, output, and activity.
- Path params:
id. - Response:
200 OK—SessionDetail:{ "snapshot": { /* SessionSnapshot, as above */ }, "system_prompt": "string", "initial_message": "string", "output": "string", "activities": [ { "label": "string", "tool_name": "string" } ], "worker_name": "string", "task": "string" } - Status codes:
200,404.
Cancel a running session.
- Path params:
id. - Response:
204 No Content. - Status codes:
204,404,409/422.
Liveness/health check. Exempt from authentication.
- Response:
200 OK{ "status": "string", "version": "string", "uptime_seconds": 0.0 }
Fetch the server log buffer.
- Response:
200 OK{ "content": "string" }
List models available to the operator/runtime.
- Response:
200 OK—PaginatedResponse<ModelInfo>(all models;total = len(items)).{ "items": [ { "id": "string", "name": "string", "provider": "string", "state": "string", "max_context_length": 0, "loaded_context_length": 0 } ], "total": 0 }
List the provider/model catalog (known providers and their models).
- Response:
200 OK—PaginatedResponse<CatalogProvider>.{ "items": [ { "id": "string", "name": "string", "api": "string", "doc": "string", "env": ["string"], "models": [ { "id": "string", "name": "string", "family": "string", "tool_call": false, "reasoning": false, "structured_output": false, "open_weights": false, "context_limit": 0, "output_limit": 0, "input_cost": 0.0, "output_cost": 0.0 } ] } ], "total": 0 }
Add (configure) a provider.
- Request body:
{ "id": "string", "name": "string", "type": "string", "endpoint": "string", "api_key": "string" } - Response:
201 Created—{ "status": "ok" }. - Status codes:
201,400,422(invalid provider ID),409.
Update an existing provider. Same request body as POST /api/v1/providers.
- Response:
200 OK—{ "status": "ok" }. - Status codes:
200,400,404,422.
List the IDs of all configured providers.
- Response:
200 OK— a JSON array of strings:["anthropic", "openai", "lmstudio"]
List the models offered by a specific configured provider (queries the provider live).
- Path params:
id. - Response:
200 OK—PaginatedResponse<ModelInfo>(shape asGET /models). - Status codes:
200,502(provider_error) when the provider is unreachable or misconfigured.
List configured MCP servers and their tools.
- Response:
200 OK—PaginatedResponse<MCPServerStatus>.{ "items": [ { "name": "string", "transport": "string", "state": "string", "error": "string", "tool_count": 0, "tools": [ { "namespaced_name": "string", "original_name": "string", "server_name": "string", "description": "string", "input_schema": { } } ] } ], "total": 0 }
Snapshot of the full orchestration progress state (the same payload pushed via the progress.update SSE event).
- Response:
200 OK—ProgressState:{ "jobs": [ /* Job[] */ ], "tasks": { "<job_id>": [ /* Task[] */ ] }, "reports": { "<job_id>": [ /* ProgressReport[] */ ] }, "active_sessions": [ { "id": "string", "worker_id": "string", "job_id": "string", "task_id": "string", "status": "string", "model": "string", "provider": "string", "tokens_in": 0, "tokens_out": 0, "started_at": "2026-06-28T12:00:00Z", "ended_at": "2026-06-28T12:00:00Z", "cost_usd": 0.0 } ], "live_snapshots": [ /* SessionSnapshot[] */ ], "active_graph_nodes": [ { "session_id": "string", "job_id": "string", "task_id": "string", "node": "string", "started_at": "2026-06-28T12:00:00Z" } ], "feed_entries": [ { "id": 0, "job_id": "string", "entry_type": "string", "content": "string", "metadata": { }, "created_at": "2026-06-28T12:00:00Z" } ] }
Get server settings.
- Response:
200 OK— aservice.Settingsobject (serialized as-is).
Update server settings.
- Request body: a
service.Settingsobject. - Response:
200 OK—{ "status": "ok" }. - Status codes:
200,400(validation failure — settings validation errors are user-actionable and returned asbad_request).
Server-Sent Events stream of the unified service event feed. A single connection receives every event type below.
- Response headers:
Content-Type: text/event-stream,Cache-Control: no-cache,Connection: keep-alive,X-Accel-Buffering: no. - Connection limit: max 10 concurrent SSE connections; exceeding it returns
429 too_many_requests. - Heartbeats: a
heartbeatevent is emitted every 15 seconds to keep the connection alive through proxies. - Resume: clients may send a
Last-Event-IDheader (the numericseqof the last event they saw). The server replays buffered events newer than that seq from an in-memory ring, then continues live. Events older than the ring are not replayed — clients should resync full state via REST after a long disconnect.
Each event is written in standard SSE framing:
id: <seq>
event: <type>
data: <json-envelope>
The data line is a JSON envelope:
{
"seq": 0,
"type": "string",
"timestamp": "2026-06-28T12:00:00Z",
"turn_id": "string",
"session_id": "string",
"operation_id": "string",
"payload": { }
}seq— monotonically increasing global sequence number (used for dedupe andLast-Event-IDresume). Aseqof0denotes a synthetic/test event delivered unconditionally.turn_id— set on operator events (correlates to thePOST /operator/messagesturn). Omitted otherwise.session_id— set onsession.*events. Omitted otherwise.operation_id— set onoperation.*events. Omitted otherwise.payload— shape depends ontype(see below).nullfordefinitions.reloaded.
type |
Payload fields | Notes |
|---|---|---|
operator.text |
text, reasoning |
Batched operator text tokens. Carries turn_id. |
operator.tool_call |
name, args, result, is_error |
Operator invoked one of its tools. |
operator.done |
model_name, tokens_in, tokens_out, reasoning_tokens |
Operator finished a turn. Carries turn_id. |
blocker.added |
request_id, source, job_id, task_id, questions[] (question, options[]), created_at |
An ask_user request is awaiting a human response. |
blocker.resolved |
request_id |
A pending blocker was answered or cancelled. |
job.created |
job_id, title, description |
|
task.created |
task_id, job_id, title, graph_id |
graph_id may be empty. |
task.assigned |
task_id, job_id, graph_id, title |
|
task.started |
task_id, job_id, graph_id, title |
|
task.completed |
task_id, job_id, graph_id, summary, recommendations, has_next_task |
|
task.failed |
task_id, job_id, graph_id, error |
|
job.completed |
job_id, title, summary, status, workspace, started_at, ended_at, tasks_total, tasks_completed, tasks_failed, tokens_in, tokens_out, cost_usd, files_touched[] (path, size, is_new), files_touched_extra |
Fires when all tasks reach a terminal state; status may be failed. |
progress.update |
state (full ProgressState, see GET /progress) |
Replaces the legacy SQLite polling loop. |
session.started |
session_id, worker_name, task, job_id, task_id, system_prompt, initial_message |
Carries session_id. |
session.text |
text |
Worker text tokens. Carries session_id. |
session.reasoning |
text |
Worker chain-of-thought chunk. Carries session_id. |
session.tool_call |
tool_call (id, name, arguments) |
Carries session_id. |
session.tool_result |
result (call_id, name, result, error) |
Carries session_id. |
session.done |
worker_name, job_id, task_id, status, final_text |
status is completed/failed/cancelled. Carries session_id. |
session.prompt |
session_id, system_prompt, initial_message |
Fills prompt fields on an existing slot (graph-node sessions). |
session.meta |
session_id, model, provider, temperature, thinking |
Resolved model/sampling settings for a session. |
definitions.reloaded |
(none — payload is null) |
Definition files changed on disk and were reloaded. |
operation.completed |
kind, result (operation_id, content, error) |
Async operation finished. Carries operation_id. |
operation.failed |
kind, error |
Async operation failed. Carries operation_id. |
heartbeat |
server_time |
Keepalive, every 15s. |
graph.node_started |
job_id, task_id, node |
A rhizome graph node began. |
graph.node_completed |
job_id, task_id, node, status |
A rhizome graph node finished. |
graph.completed |
job_id, task_id, summary |
A rhizome graph finished successfully. |
graph.failed |
job_id, task_id, error |
A rhizome graph execution failed. |
Client-only events (synthesized by RemoteClient, never sent by the server, but defined in service.EventType for the unified stream):
type |
Payload fields | Notes |
|---|---|---|
connection.lost |
error |
SSE connection to the server dropped. |
connection.restored |
(empty object) | SSE connection re-established. |
The middleware stack (applied in Server.Start, outermost first) is:
recovery → request ID → auth → logging → CORS → security headers → content-type
- Recovery — recovers from handler panics, returns
500. - Request ID — sets/propagates
X-Request-ID(validated: <= 64 chars, alphanumeric +-_.); included in logs and surfaced on responses. - Auth — bearer-token enforcement (see Authentication); exempts
GET /api/v1/health. - Logging — structured per-request logging (method, path, status, duration, request ID).
- CORS — localhost origins only; handles
OPTIONSpreflight with204. - Security headers — defensive response headers.
- Content-Type — enforces/normalizes
application/jsonhandling.
Server timeouts: ReadHeaderTimeout 10s, WriteTimeout 30s, IdleTimeout 120s. Each individual SSE event write has a rolling 30s deadline.
33 routes registered in registerRoutes:
- Operator (6):
POST /operator/messages,POST /operator/prompts/{requestId}/respond,GET /operator/status,GET /operator/history,GET /operator/blockers,PUT /operator/provider - Skills (5):
GET /skills,GET /skills/{id},POST /skills,DELETE /skills/{id},POST /skills/generate - Graphs (2):
GET /graphs,GET /graphs/{id} - Jobs (3):
GET /jobs,GET /jobs/{id},POST /jobs/{id}/cancel - Tasks (1):
POST /tasks/{id}/retry - Sessions (3):
GET /sessions,GET /sessions/{id},POST /sessions/{id}/cancel - System (12):
GET /health,GET /logs,GET /models,GET /catalog,POST /providers,PUT /providers,GET /providers/configured,GET /providers/{id}/models,GET /mcp/servers,GET /progress,GET /settings,PUT /settings - SSE (1):
GET /events