Skip to content

Latest commit

 

History

History
605 lines (510 loc) · 42.8 KB

api.md

File metadata and controls

605 lines (510 loc) · 42.8 KB

HTTP & Socket.IO API

Base URL http://127.0.0.1:7100. HTTP routes are under /api; streaming is Socket.IO on the /assistant namespace. Callers are identified as configured by ACCESS__MODE (see below and access); the default treats every caller as the local operator, so bind to localhost. Interactive OpenAPI docs are served at /docs.

Who is calling

Every route and the Socket.IO connection establish a principal as configured by ACCESS__MODE (see access): the local operator by default, the X-Assistant-Principal / X-Assistant-Roles headers behind an authenticating proxy, or the host's callback. The one exception is GET /api/media/{id}, which a browser fetches as an image without headers: it is an expiring capability URL (a random id) and establishes no principal. An unidentified caller gets 401 (a refused connection on Socket.IO); a session that belongs to someone else (or an unowned legacy session, for a non-administrator) 403 (assistant:error of type forbidden); administration without the admin role 403. Administration covers PATCH /api/settings, providers, OAuth, ingress, the inbox, debugging, every artifact mutation, session reassignment, host cards, and the event, action and host-state routes.

Health

GET /health is liveness: {"healthy": bool, "status": "ok" | "degraded" | "unhealthy", "runtime": "assistant-runtime", "components": {name: {...}}}. It answers 503 only when status is unhealthy: a component reports that it cannot work, so the runtime cannot answer a turn (for example, no provider key is configured). A service that fails to start stops startup, so no probe gets an answer at all. degraded answers 200. The process is running and recovers by itself, but something it started with is unavailable. Today that is only a database chosen at startup that no longer answers: database_service: {"healthy": true, "reachable": false, "ready": false}. Optional dependencies never make the runtime unhealthy. An unconfigured integration reports "status": "disabled". A database that was unreachable at startup reports ready: true, because that process keeps its state in memory whatever the server does later. The database probe only reports; whether sessions live in memory was decided at startup.

GET /health/ready is readiness. It returns the same body, with 200 only when status is ok and 503 otherwise. Point a liveness probe (restart on failure) at /health, and a readiness probe (hold traffic) at /health/ready. During a database outage, a restart cannot help: with DATABASE__REQUIRED the runtime would fail to start, and without it the runtime would start in memory mode without the data it was using.

An anonymous caller gets healthy, status, and each component's healthy flag, plus ready where the component reports one. An authenticated caller gets the full component detail. Every caller is authenticated in trusted_local, unless ACCESS__LOCAL_TOKEN is set; then the token is required. runtime is the marker serve looks for before replacing a previous instance on its port.

A route that uses Postgres answers 503 with {"error": "Database not reachable", "type": "DatabaseError"} when the database was lost after startup. The loss is temporary: every call tries the database again, so a host may retry.

Chat (non-streaming)

POST /api/chat with a message body (below) runs the same turn pipeline as the socket and returns {"content", "model", "session_id", "turn_number", "message_id", "usage", "pending_tool_call", "decision"} when the turn ends. pending_tool_call is set when the turn stopped on a host tool call; answer it with a continuation body (tool_call_id, tool_result). A request that does not fit the session is a 409 and is not worth retrying: unknown session or parent, duplicate message id, a continuation whose tool_call_id is not the pending one (or nothing is pending), steering when the session has no conversation yet. A failed run is a 500. Both carry {"error", "type"}. Steering messages are rejected here (422); use the socket.

For silent independent controllers, output_mode: "host_tools" returns content: null with decision: "hold" | "pending" | "completed". Normal text turns have decision: null. See the complete decision/receipt contract.

Turn control

POST /api/chat/{session_id}/cancel requests cancellation and returns {"cancel_requested": true} when a cancellable turn was found, or false when none was found. The response acknowledges the request; finalization may still be running. It also works during dependency/model setup and needs no Postgres. An idle pending host action is not a live turn to cancel.

Cancellation saves the partial assistant text, completed tool results and available usage in the session. Outstanding calls are recorded as outcome: "interrupted"; their external effects may already have occurred. Cancellation does not undo those effects. A continuation result already accepted from the host is retained on the same assistant message.

A connected stream receives one final_response with error: true, error_type: "cancelled" and its saved message_id, a terminal error, then status: completed. Text already streamed is not repeated in the final event. The original non-streaming chat request follows the existing run-error response; read /api/sessions/{id}/messages for its saved work.

New ordinary messages cancel the previous turn and wait for its cleanup before starting. Continuations wait for the prior turn to finish without cancelling it. Socket.IO disconnects leave execution running; reconnecting does not replay missed events, so reload the session. What survives a restart, how a pending host action is recovered and why duplicate continuations are rejected is in persistence and recovery; none of it provides exactly-once external actions.

Queued steering can be consumed together. Scheduling a steering request already delivered by another turn returns a terminal session_error without starting another model run.

AG-UI (POST /api/agui)

The same turn pipeline behind the AG-UI protocol, for frontends built on @ag-ui/client, CopilotKit or any AG-UI client. It needs the ag-ui extra (pip install "assistant-runtime[ag-ui]"); without it the route answers 501. The body is an AG-UI RunAgentInput, the response is Server-Sent Events encoded by Pydantic AI's AGUIEventStream (RUN_STARTED, TEXT_MESSAGE_*, THINKING_*, TOOL_CALL_*, TOOL_CALL_RESULT, RUN_FINISHED or RUN_ERROR).

AG-UI Runtime
threadId the session id; ownership and administration apply as for every other route
last message is a user message a new message appended to the session's active leaf; its id is the message id
trailing tool messages continuations of the session's pending host actions, in order (toolCallId = the call_id, content = the result, JSON when it parses; error set makes it tool_outcome: "failed"); when a response asked for several host tools, answer them all in one run and the model resumes once
earlier messages ignored: the server-side tree is the conversation; the resent transcript is not replayed into the model
tools request-declared host actions (host_context.actions) for this run only: every AG-UI request carries its own host context, so a tool not sent again is not available (unlike host_context omitted on the message body, which reuses the session's last context); the model's call ends the run with TOOL_CALL_* events and the client answers with a tool message in its next run
context host_context.background (description → value)
state the host context itself when it carries version: 1; otherwise host_context.extensions.state
forwardedProps.profile registered assistant profile name (same as top-level profile on chat); send on continuations too
forwardedProps.config the per-request tunable overrides (same fields as config in the message body)
image and document user content (and legacy binary) reference attachments for the model (data URIs or URLs); audio and video content is not mapped
client disconnect cancels the turn (partial work is saved, as for any consumer that goes away)
a terminal runtime error (session error, provider error, usage limit) RUN_ERROR with the message; a cancelled turn ends with RUN_FINISHED because AG-UI has no cancelled outcome

Not supported over AG-UI: steering, branching (parent_id), resume[] approvals, runtime-emitted STATE_SNAPSHOT/STATE_DELTA/MESSAGES_SNAPSHOT, and Socket.IO-only fields. Session routes (/api/sessions/...) work on the same session, so an AG-UI client can read the tree, the pending action and usage. A minimal browser example is in examples/agui/index.html.

The message body

Sent to POST /api/chat and as the payload of assistant_message. Keys may be camelCase; they are normalised.

Field Type Notes
id string client-generated message id, 1 to 64 characters (the stored column width; longer is a 422)
session_id string created on first use; 1 to 64 characters. An AG-UI threadId is a session id and has the same limit
content string the text; may be empty on a continuation
parent_id string, optional, at most 64 characters the message to branch from; omitted, the message continues from the session's active leaf (the first message is the root)
output_mode text (default) or host_tools silent single host-action decision; receipts inherit the pending mode
message_type standard (default) or steering see concepts
attachments list, optional images, documents or text for the model, or a screenshot for look_at_screen; shape in the host contract
images list of data URLs, optional legacy: screenshots; a top-level screenshot is folded in
host_context object, optional what the host shows, version 1 of the host contract; invalid content is a 422
config object, optional per-request overrides: default_model, thinking_budget, temperature, max_turns, enable_working_memory, summarization_model, working_memory_model, default_image_model, default_video_model, subagent_model, subagent_thinking_budget, codex_service_tier (see configuration). Budgets can only be lowered; when the host sets ASSISTANT__REQUEST_MODELS, a model outside that list keeps the host's value
tool_call_id, tool_result continuation only the pending host tool's call id and its result
tool_outcome continuation only success (default) or failed: the host could not perform the action; tool_result is then the failure the model reads

Socket.IO, namespace /assistant

Client to server

Client-to-server event names use underscores (they are dispatched to on_<event> handlers); server-to-client events use the assistant: prefix. An event the server has no handler for is ignored silently, so a misspelt name produces no response at all.

Event Payload Effect
assistant_join_session {"session_id", "host_context"?} join the session room and warm it (tools, prompt inputs)
assistant_message the message body start a turn after prior-turn cleanup; ordinary messages cancel a live turn, continuations wait
assistant_cancel {"session_id"} request cancellation and snapshot persistence for the live turn

Server to client

Each event is a JSON object with the fields below; all are sent only to the requesting socket, except the two artifact events, which go to the room of the session they happened in (clients join it with assistant_join_session).

Event Fields
assistant:status status: started or completed (terminal)
assistant:thinking_delta content, plus segment metadata
assistant:text_delta content, plus segment metadata
assistant:tool_call tool_name, arguments, call_id, category (backend or host)
assistant:tool_result tool_name, output, call_id, duration_ms?, invalidates?
assistant:tool_error tool_name, error, call_id
assistant:final_response content, model, streamed, session_id?, message_id?, trace_id?, usage?, error?, error_type? (including cancelled), decision? (hold, pending, completed), pending_tool_call? ({tool_name, call_id, arguments, queued})
assistant:error type, message, error_type?, terminal?, retry_allowed?; turn errors use type: "error" and a specific error_type: rate_limit, provider_error, connection_error, timeout, provider_auth, provider_client_error for provider failures (a timed-out or interrupted provider request is timeout, retryable), usage_limit, cancelled, session_error, forbidden, setup_error, and internal only for failures inside the runtime
assistant:artifact_proposal profile, subject, name, version, active_version, rationale, proposed_by ({kind, label}), session_id
assistant:artifact_decision profile, subject, name, version, status (active when approved, or rejected), decided_by, decision_reason, session_id
assistant:debug type is one of debug_request, debug_system_prompt, debug_history, debug_tool_selection, debug_agent_config, debug_thinking, debug_final_response, debug_usage, debug_error, debug_completed; off by default, on with STREAMING__EMIT_DEBUG_EVENTS=true

Segment metadata on deltas: segment_id, segment_index, delta_index, segment_started, segment_kind (text or thinking), so a client can rebuild the interleaving of thinking, text and tool groups.

Host tool flow. tool_call with category: "host", then final_response with pending_tool_call, then status: completed. The client performs the action and sends assistant_message with tool_call_id = pending_tool_call.call_id, tool_result = whatever the action produced (any JSON), and content empty. The model resumes.

Usage

final_response.usage and each stored assistant message carry {input_tokens, output_tokens, total_tokens, requests, tool_calls, cost_usd, auxiliary?}; cost_usd is null when the provider reports no price, and auxiliary holds summarization and working_memory usage when those ran. A turn that reaches a usage limit ends with final_response.error_type and a terminal error.error_type of usage_limit (retry_allowed: false), with the partial message saved. See concepts.

A continuation's final_response.usage is cumulative for its assistant message: it includes the requests made before the host action. Sum the latest usage of each assistant message for a session total, not every final_response.

Change cursors

A host that mirrors the runtime's records into its own index reads what changed since its last pass. Messages, tasks, agent messages, actions, events and voice calls carry updated_at, which every write sets, so a message reappears when a tool result or a host result is recorded on it later, and a task, action or event when its status moves.

A change cursor is <ISO 8601 updated_at>|<id>, for example 2026-10-06T12:00:00.123456+00:00|t-42, or a bare ISO time, which means everything changed strictly after that time. A time without a timezone is UTC. A list read with a cursor is ordered by (updated_at, id), oldest change first, and returns the records after it; continue from the last record (next_cursor, or build it from the last record's updated_at and id where the list is a bare array). A malformed cursor, or an id of the wrong type (actions and events have integer ids), is 422. Encode the cursor in the query string (+ is %2B; an unencoded + before the offset is still read).

With Postgres, updated_at is the time of the writing transaction, not of its commit, so a change can become visible after a reader has passed its time. Re-read with a few seconds of overlap (start from a cursor a little earlier than the last one) and deduplicate by id. Without Postgres it is the process clock.

Route Cursor parameter
GET /api/sessions/{id}/messages/changes after, returns next_cursor
GET /api/tasks, GET /api/agents/{id}/messages updated_after; not with before
GET /api/actions updated_after, adds next_cursor; not with before
GET /api/events updated_after, next_cursor in place of next_after; not with after
GET /api/voice/calls updated_after, returns next_cursor

The other filters of a list still apply, and so does its limit. GET /api/counts gives totals to check a mirror against.

Sessions

Route Returns
GET /api/sessions?limit=50&offset=0&updated_after= [{session_id, owner_id, title, turn_number, message_count, created_at, updated_at}], most recently updated first; the caller's own sessions, every session for an administrator. updated_after (ISO 8601, UTC when it has no timezone) keeps those updated strictly later; without Postgres updated_at is the session's last use (reads included)
GET /api/sessions/{id} turn count, message count, pending_action (tool_call_id, tool_name, arguments, assistant_message_id, queued: further call ids from the same response still to be handed over, or null): everything a host needs to perform the waiting action and continue
GET /api/sessions/{id}/messages?leaf_id= the root-to-leaf path for display (see below); leaf_id selects another leaf's path, for branch switching
GET /api/sessions/{id}/tree every message with its parent_id
POST /api/sessions/{id}/messages {"segments", "content"?, "id"?, "profile"?, "subject"?} add a card the host made outside any turn as a host message at the active leaf (201, the display row); administration. A missing session is started, owned by the caller, and profile and subject bind a session the card starts as a first message's would. 409 while a turn, a pending host action or a voice call has the session, for a repeated id, or for a profile or subject other than the session's binding; 404 for an unknown profile
GET /api/sessions/{id}/messages/changes?after=&limit=100 {messages, next_cursor, active_leaf_id}: the session's messages changed after the after change cursor, oldest change first, each a /tree row with updated_at; from the beginning without after. next_cursor continues after the last row, or echoes after when nothing changed; limit is 1 to 500
GET /api/sessions/{id}/messages/{message_id} one message in the display form /messages uses; 404 when the session or the message is unknown, or the message is another session's
GET /api/sessions/{id}/messages/{message_id}/prompt the system prompt an assistant message was produced with (see below); 404 when none is recorded
GET /api/sessions/{id}/traces?limit= debug traces (Postgres)
POST /api/sessions/{id}/repair resolve the pending host action and every call without a result as unknown, so the session can continue
DELETE /api/sessions/{id} delete the session
PATCH /api/sessions/{id}/owner {"owner_id"} assign the session to a principal (administration; null makes it unowned)

Stored messages

GET /api/sessions/{id}/messages returns the path from the root to the active leaf (or to leaf_id), merged by time with the session's delivered and promoted steering, ordered by time. Steering is recorded per session, not per message, so when leaf_id selects another branch the same steering rows appear next to that path too. The route is 404 for a session the runtime does not know; a session that was joined but has no messages yet returns []. Four row shapes:

role Fields
user id, parent_id, text, message_type (standard), timestamp
assistant id, parent_id, text (the text segments joined), segments, usage (see usage), timestamp
host id, parent_id, text (its content), segments (its components), timestamp
steering id, text, message_type: "steering", status (delivered or promoted), timestamp; no parent_id, steering is outside the tree

segments is the assistant message in order. Each segment carries segment_id (segment_<n>) and segment_index, and is one of:

  • {"kind": "thinking", "text"} and {"kind": "text", "text"};
  • {"kind": "tool_group", "tools": [...]}, where each tool entry has id (the call id), name, input (the arguments), and, once a result is recorded, output, plus outcome when it is not success (failed, denied, interrupted) and status when the action did not complete (failed, cancelled, superseded, unknown; see persistence). An entry without output is the pending host action. Entries do not say whether a tool is a host or backend tool; the streamed tool_call event does (category), and a host knows its own action names.

A host message is a card the host showed outside any turn (a proposal made in the background, findings, an action a task suggested). Its segments are components, {"kind": "component", "type", "data"} (up to 16, 64 KiB of JSON in all; a type is a lowercase letter followed by up to 63 lowercase letters, digits, _, . or -), for people only: the model never receives them. content holds at most 20,000 characters. In later turns the model reads the message's content, as request-side text, before the next prompt; an empty content leaves it out entirely. The next reply continues after the host message.

GET /api/sessions/{id}/tree returns every message with id, parent_id, role, message_type, content and created_at, for drawing branches.

Settings, models, providers

Route Purpose
GET /api/settings every tunable with its value and source tier
PATCH /api/settings change the runtime overlay; omitted fields unchanged, null clears an override
GET /api/models the model catalog, provider status, and the defaults a request gets now (runtime overrides applied)
GET /api/providers providers with auth status and key source
PUT /api/providers/{provider}/api-key {"api_key"} store a key encrypted (needs OAUTH__ENCRYPTION_KEY, Postgres) and activate it
DELETE /api/providers/{provider}/api-key remove a stored key
POST /api/oauth/openai/device-code start ChatGPT/Codex device auth; returns user_code, verification_uri, expires_in
POST /api/oauth/openai/codex-cli/sync import the local Codex CLI auth
GET /api/oauth/openai/status connection status
DELETE /api/oauth/openai disconnect

Prompt artifacts

Versions are durable with Postgres and kept in memory otherwise; every mutation response and the profile carry durable. The routes act as the profile's host actor: a policy denial is 403, an unknown name or a text longer than the artifact's max_chars 422, a stale expected_version 409, a missing version 404.

Route Purpose
GET /api/artifacts active version of every artifact, in prompt order
GET /api/artifacts/profile the profile: artifacts, roles, policies, live versions
GET /api/artifacts/{name} the active version, or the default text (source: "default")
GET /api/artifacts/{name}/history all versions, newest first
GET /api/artifact-prompt?profile=&subject= the system prompt a turn would start from now, as a prompt record with content (no host context or session memory)
GET /api/artifact-subjects?profile= the subjects a profile keeps subject-scoped versions for
GET /api/artifact-proposals?profile=&status=&limit=&before_id= {proposals, next_before}: versions in status (pending by default) as proposal records, newest first, limit per page (100 by default, at most 500); without profile, every registered profile merged; pass next_before as before_id for older ones (null on the last page)
GET /api/artifacts/{name}/versions/{version} one version as a proposal record
POST /api/artifacts/{name}/propose {"content", "expected_version"?, "rationale"?} new pending version (201), attributed to the calling principal
PATCH /api/artifacts/{name} {"content", "expected_version"?} new version, active at once
POST /api/artifacts/{name}/approve/{version} activate a version (a pending one is approved; a rejected one is 409: propose it again)
POST /api/artifacts/{name}/reject/{version} {"reason"?} reject a pending version; the active one is unchanged (409 if it is not pending)
POST /api/artifacts/{name}/rollback/{version} reactivate an older version
POST /api/artifacts/{name}/actions {"action": "propose"|"update"|"approve"|"reject"|"rollback", ...} the five above behind one endpoint
DELETE /api/artifacts/{name} delete every version; the default applies again

Host writes (propose, PATCH, approve, reject, and the same through /actions) take an optional label (lowercase, up to 32 characters, for example owner or import), recorded after the principal as proposed_by or decided_by (local:owner): who in the host made the change. It never changes what the host may do. Principal id and label together hold at most 128 characters; a longer pair is 422.

Every artifact route also takes ?subject=, needed (422 otherwise) for the profile's subject-scoped artifacts and ignored by the others; versions and records carry the subject. GET /api/artifacts/profile reports each artifact's scope, keep_versions and max_chars, the profile's include, and its collections with the documents they hold.

Every version has a status: a proposal is pending until it is approved (active) or rejected; an active version that another replaces becomes superseded. Versions also carry actor_kind (assistant, host, or seed for a default stored at startup under DATABASE__REQUIRED), rationale, decided_by, decided_at and decision_reason. A proposal record is what a host renders for review:

{"id": 42, "profile": "neutral", "subject": null, "name": "instructions", "role": "...",
 "version": 3, "status": "pending", "content": "...", "rationale": "...",
 "proposed_by": {"kind": "assistant", "label": "assistant"}, "created_at": "...",
 "active_version": 2, "active_content": "...", "diff": "--- instructions (v2)\n+++ ...",
 "decided_by": null, "decided_at": null, "decision_reason": null}

diff is a unified diff from the active text (the default when no version is active) to the version. An approval takes effect on the next turn.

Each assistant message records the system prompt it was produced with, independent of debug events and kept as long as the session:

{"profile": "neutral", "subject": null,
 "artifact_versions": {"instructions": 4, "owner.preferences": null},
 "snapshot_hash": "…", "dynamic": [["working_memory", "…"]],
 "suffix": "", "fragments": [{"name": "instructions", "chars": 812}, …],
 "message_id": "…", "content": "the exact text"}

artifact_versions names the version behind each artifact text (null where the default applied); the stable text is stored once per snapshot_hash. A host continuation replaces the record with the prompt of its own model request. The host context is not part of the system prompt: it travels with the user message (see the host contract).

Artifact names come from the active profile; GET /api/artifacts/profile lists them. The built-in technical_operator profile defines soul, persona, communication_protocol, ecosystem and scratchpad; the default neutral profile defines one optional instructions artifact, empty until the host writes it.

Messages from other systems

Route Purpose
POST /api/assistant/inject {"from", "via", "message", "sessionId"?, "telegramChatId"?} deliver a message with a [via:<via> from:<from>] envelope into a session; {"status": "delivered", "session_id", "delivery"} (delivery is queued into a live turn or promoted to a turn of its own) or {"status": "queued", "inbox_id"} when no session exists, or {"status": "queued", "inbox_id", "session_id"} when a voice call holds the named session (the message joins that session's next turn)
POST /api/inbox {"from", "message", "severity"?, "context"?} leave a note (context.session_id and context.via are honoured); same delivery and response as above
GET /api/inbox?surfaced= list the queued notes (Postgres)
PATCH /api/inbox/{id}/surfaced mark a note as surfaced (Postgres)
GET /api/assistant/sessions sessions in the shape agent-backbone expects

Background tasks

A task is a bounded piece of work run as a turn in its own session (task-<id>), outside any conversation turn: the caller keeps serving while it runs. Tasks about the same profile and subject run one at a time, in order; at most TASKS__MAX_CONCURRENT run at once. They need TASKS__ENABLED=true; the model gets start_task, list_tasks, get_task and cancel_task, with the calling session as the parent.

Route Purpose
POST /api/tasks {"task", "profile"?, "subject"?, "context"?, "parent_session_id"?} queue a task (202); 503 when disabled, 429 when too many wait, 422 for an empty task or an invalid subject name, 404 for a profile that isn't registered (whatever its spelling)
GET /api/tasks?parent_session_id=&status=&limit=&before=&updated_after= the caller's tasks newest first (every task for an administrator); before names a task the caller can see and lists only older ones (by created_at, then id), 404 when unknown. updated_after (a change cursor, not with before) lists the tasks changed after it instead, oldest change first
GET /api/tasks/{id} one task: status (queued, running, done, failed, cancelled, interrupted), result, error, usage, times
POST /api/tasks/{id}/cancel stop a queued or running task

When a task ends, task_finished is published on app.state.events with task_id, status, session_id, parent_session_id, profile, subject, result and error. Nothing is steered into the parent session: the host decides when and how a result is reviewed. With Postgres, a restart marks unfinished tasks interrupted (and publishes that); nothing is replayed. Without it, tasks live in process memory and are lost on restart.

Persistent agents

A persistent agent keeps one continuing session instead of a fresh one per piece of work. The host starts it for a profile and subject (one active agent per pair and per session), moves it to a fresh session when its conversation should start over, and stops it. Each message sent to it runs as the next turn in that session, one at a time and in order, within the same TASKS__MAX_CONCURRENT as tasks; no task is started. A message takes the agent's session when its turn starts, so a move applies to messages still waiting. The same TASKS__ENABLED switch turns agents on; the model gets message_agent (by profile and subject), list_agents and get_agent_message, with the calling session as the sender. Starting, configuring, moving, stopping and messaging an agent over HTTP requires the admin role.

An agent's config takes the same fields as a request's config (the request tier: budgets can only be lowered). Each message's turn reads it when the turn starts, as it reads the agent's session, so a change applies to messages still waiting. POST and PATCH answer 422 for a value a turn would ignore: a *_model outside ASSISTANT__REQUEST_MODELS when that list is set, or codex_service_tier while ASSISTANT__REQUEST_SERVICE_TIER is off. A host shows an agent's effective values from its config, then GET /api/settings for the fields it leaves unset; a message's usage.service_tiers says which tier was served.

Route Purpose
POST /api/agents {"profile"?, "subject"?, "session_id"?, "config"?} start an agent (201); its session is agent-<id> unless named; 409 when an active agent has that profile and subject or that session, 422 for a refused config, 503 when disabled
GET /api/agents?status= the caller's agents newest first (every agent for an administrator); status is active or stopped
GET /api/agents/{id} one agent: session_id, profile, subject, config (the fields set), status, times
PATCH /api/agents/{id} {"config"} change its config as PATCH /api/settings does: omitted fields are unchanged, null clears one; 409 when stopped, 422 for a refused value
POST /api/agents/{id}/session {"session_id"?} move the agent to a fresh session (a new id unless one is given); 409 when stopped or the session is taken
POST /api/agents/{id}/stop stop the agent; its queued and running messages end cancelled
POST /api/agents/{id}/messages {"content", "parent_session_id"?} queue a message (202); 409 when the agent is stopped, 429 when too many wait
GET /api/agents/{id}/messages?parent_session_id=&status=&limit=&before=&updated_after= the agent's messages newest first; before names one of them and lists only older ones (by created_at, then id), 404 when unknown. updated_after (a change cursor, not with before) lists the messages changed after it instead, oldest change first
GET /api/agents/{id}/messages/{message_id} one message: status (as for tasks), session_id it ran in, result, error, usage, times

When a message's turn ends, agent_message_finished is published on app.state.events with message_id, agent_id, status, session_id, parent_session_id, result and error. A turn another caller starts in the agent's session replaces a running message turn, as in any session. With Postgres, agents survive a restart and unfinished messages are marked interrupted (and published); nothing is replayed.

Events

The host's record of what other systems reported (inbound) and of the notices it shows the owner (outbound), all administrator routes. An event is stored once per (source, event_id); ids increase in arrival order (a repeated key can leave a gap), so a reader continues with after. An inbound event is only stored unless it names target_session_id: then it is steered into that session as [via:<source> from:<agent>] <kind> (<severity>): <summary>, queued behind a running turn, or waiting for the session's next turn while a voice call holds it. Imported history (history: true) is never steered.

Route Purpose
POST /api/events {"event_id", "direction", "source", "kind", "agent"?, "severity"?, "summary"?, "payload"?, "target_session_id"?, "history"?, "occurred_at"?} store an event (201), or return the stored one for a repeated key (200; delivered then only if no request ever started delivering it, as when the process stopped right after storing it); severity is info (default), warning or critical; occurred_at needs a UTC offset
GET /api/events?after=&direction=&source=&agent=&kind=&news_only=&limit=&updated_after= {events, next_after} in arrival order; news_only=true leaves out history. updated_after (a change cursor, not with after) lists the events changed after it instead, oldest change first, as {events, next_cursor}
GET /api/events/{id} one event: its status (received or delivered inbound; pending, delivered, heard outbound), delivery ({session_id, how} with how queued, promoted or inbox, {session_id, error}, or {session_id} alone while delivery runs, or when the process stopped during it and the outcome is unknown), and times
PATCH /api/events/{id} {"status": "delivered"|"heard"} move a notice forward (409 backwards or for an inbound event)

Actions

Records of actions a host proposes and carries out through its own confirmation flow (drafts the owner edits, scheduled sends, undo, results per recipient), all administrator routes. The runtime stores them and never acts on them. A status is proposed, scheduled, sending, sent, failed, discarded or undone; which follows which is the host's. Each status change is appended to history with its time and principal; new text raises revision and keeps the first text as proposed_text.

Route Purpose
POST /api/actions {"kind", "text"?, "arguments"?, "profile"?, "subject"?, "status"?} record an action (201), proposed by default; id is an increasing integer
GET /api/actions?status=&kind=&profile=&subject=&limit=&before=&updated_after= {actions}, newest first (by id); before names an action and lists only lower ids, 404 when unknown. updated_after (a change cursor, not with before) lists the actions changed after it instead, oldest change first, as {actions, next_cursor}
GET /api/actions/{id} the action with its confirmations in insertion order
PATCH /api/actions/{id} {"status"?, "text"?, "arguments"?, "confirmed_by"?, "decided_at"?, "results"?, "expected_status"?, "expected_revision"?} change it; results merge per recipient; with expected_status (a list) or expected_revision, only while the action matches (409 otherwise)
POST /api/actions/{id}/confirmations {"id", "recipient", "kind", "revision", "text_sha256", "source", "confirmed_at", "key_epoch"?} the owner's confirmation for one recipient, written before the send (201; a repeated id is 409); id is a UUID4, kind message or steer, source button, typed or voice, confirmed_at with a UTC offset
PATCH /api/actions/{id}/confirmations/{cid} {"key_epoch"?, "sender"?, "audience"?, "status"?, "result"?, "reconciled"?} sign it (only while confirmed), settle it once as sent or failed with its result, or note reconciled (matched, altered, missing, undelivered); what was confirmed never changes
GET /api/action-confirmations?after=&action_id=&status=&sender=&audience=&signed=&reconciled=&limit= {confirmations, next_after} in insertion order (seq), for reconciling against the receiver's receipts

Host state

Small values a host keeps in the runtime instead of a database of its own (the cursor it last read a source at, settings, a daily budget), under a namespace and key, all administrator routes. Each write raises the value's version; a write or delete with expected_version happens only over that version (0: the key must not exist yet), otherwise 409. A deleted key keeps counting, so a version is never reused. The runtime never reads the values.

Route Purpose
GET /api/host-state/{namespace}?after=&limit= {namespace, entries, next_after}: values by key after after; next_after continues, null on the last page
GET /api/host-state/{namespace}/{key} {namespace, key, value, version, updated_by, updated_at} (404 when absent)
PUT /api/host-state/{namespace}/{key} {"value", "expected_version"?} store any JSON value (at most HOST_STATE__MAX_VALUE_BYTES)
DELETE /api/host-state/{namespace}/{key}?expected_version= remove it (204; 404 when nothing is stored and no expected_version is named)

A namespace starts with a lowercase letter, followed by lowercase letters, digits, _, . or - (up to 64 in all); a key is 1 to 200 letters, digits or _ . : @ -.

Media and debugging

Route Purpose
GET /api/media/{image_id} a generated image from the cache
GET /api/media/video/{job_id} video job status
GET /api/debug/tools the complete tool registry and MCP server status
GET /api/counts?from=&to= {from, to, events, tasks, actions, agent_messages, messages, voice_calls}: records created in [from, to) (ISO 8601, UTC when they have no timezone; 422 unless from is before to), from Postgres when it is up, the in-memory stores otherwise (a service that is not running counts 0); administration

Decisions

GET /api/decisions/status reports whether the decision capability is configured. POST /api/decisions sends state and a map of typed questions (choice, score, noul) to the decision model in one call and returns typed answers under the same ids, with usage and the call's timing. Optional profile is validated like voice creation. No session is involved. A missing key is 503; provider errors map to 422, 429, 502 and 504 with provider_status_code, and the provider's bounded provider_detail on 422 and 429.

Voice calls

The optional voice integration (GPT-Live, or the Codex CLI's realtime voice) exposes authenticated /api/voice/status, /api/voice/usage (Codex usage diagnostics; codex provider only) and /api/voice/calls endpoints. POST an SDP offer to create a call (its persona from instructions, or from a registered profile's artifacts with instructions_profile, recorded on the call), GET its snapshot or /events SSE stream, list snapshots changed after a change cursor with GET /api/voice/calls?updated_after=&limit=100 ({calls, next_cursor}), POST /close to finalize it, POST /cancel to cancel delegated backend work, PATCH /context to update host context, send a fact to a codex call, or both, and POST /delegations/{id}/tool-result for a pending host action. The voice guide specifies request/response and event shapes. An active call reserves its runtime session: ordinary turns, cancellation, repair, deletion and reassignment return 409. A call does not use the ordinary chat continuation endpoint. Backend stream events are nested inside voice SSE envelopes, preserving the same event shapes and saved message tree.

Profile selection

Chat requests accept top-level profile, a startup-registered name from GET /api/artifacts/profile → available_profiles. Send it on every turn, steering message and host-tool continuation; omission selects the startup default. Queued steering with an explicit profile is delivered only to matching turns; unprofiled queued steering inherits its consuming turn. Unknown names reject chat with HTTP 409 before turn admission, voice creation with 422 before allocation, and artifact queries, task creation and host cards with 404. Invalid name syntax returns 422 for HTTP request validation. Artifact routes select the same scope through ?profile=<name>. See deployments.

Top-level subject (letters, digits, ., _, -; up to 128) names whom the turn is about and selects the profile's subject-scoped artifacts. The first message of a session that names a subject binds the session to that profile (the default when omitted) and subject: later requests, steering and delivered messages may omit both and inherit them, and one naming another profile or subject is a session error.