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.
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.
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.
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.
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.
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.
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 |
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 |
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.
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.
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.
| 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) |
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 hasid(the call id),name,input(the arguments), and, once a result is recorded,output, plusoutcomewhen it is notsuccess(failed,denied,interrupted) andstatuswhen the action did not complete (failed,cancelled,superseded,unknown; see persistence). An entry withoutoutputis the pending host action. Entries do not say whether a tool is a host or backend tool; the streamedtool_callevent 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.
| 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 |
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.
| 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 |
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.
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.
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) |
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 |
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 _ . : @ -.
| 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 |
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.
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.
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.