Runtime Server
The runtime executes agents and exposes chat, session, and tool APIs. Hosted deployments supply authentication and durable storage through the platform. The CLI runs a local server with in-memory storage.
Use the Platform API to manage agents, source, and deployments. Use the endpoints below to interact with a running agent.
Endpoints
Chat
| Method | Path | Response |
|---|---|---|
POST | /chat or /chat/stream | SSE stream of Amodal events |
POST | /chat/sync | Complete response after execution finishes |
POST | /chat/ai-stream | AI SDK streaming protocol |
The ordinary chat SSE stream is a POST response. Use fetch or the SDK; browser EventSource supports GET only.
A chat request whose session_id already has a turn running returns 409 {error: 'turn_in_progress'}. To send a message while a turn runs, use POST /chat/sessions/:id/messages.
Runtime auth from curl
Hosted chat requires a runtime bearer token or the hosted browser session flow. Public access to an app's static files does not grant access to chat.
With AMODAL_API_BASE, AMODAL_AGENT_ID, AMODAL_PLATFORM_TOKEN, and AMODAL_AGENT_URL set, mint a short-lived runtime token:
AMODAL_AGENT_TOKEN="$(
curl -fsS "$AMODAL_API_BASE/api/agents/$AMODAL_AGENT_ID/tokens" \
-H "Authorization: Bearer $AMODAL_PLATFORM_TOKEN" \
-H "Content-Type: application/json" \
-d '{"ttl":600}' \
| node -e 'const fs = require("fs"); const body = JSON.parse(fs.readFileSync(0, "utf8")); process.stdout.write(body.token)'
)"
curl -fN "$AMODAL_AGENT_URL/chat/stream" \
-H "Authorization: Bearer $AMODAL_AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"message":"Say hello."}'The platform token needs permission to mint runtime tokens for that agent. Keep it on your backend. See Authentication for persistent agent API keys and browser authentication.
Chat request body
| Field | Type | Description |
|---|---|---|
message | string, required | User text. May be empty when images or attachments are supplied. |
session_id | string | Continue this conversation; omit to start one. |
scope_id | string | Tenant, user, or object scope. Scope requirements depend on the host; see below. |
context | Record<string, string> | App facts for prompt templates and connection context injection. These are not trusted authorization claims. |
metadata | Record<string, string> | Observability only: recorded on the turn's trace and the session, never sent to the model. See below. |
agent | string | Named agent under agents/. Omit to use default when defined, otherwise the unscoped default chat. |
skill | string | Select a skill-scoped session. |
deploy_id | string | Deployment hint for a host that resolves bundles dynamically. It does not switch the deployment behind a fixed agent URL. |
model | {provider, model, effort?} | Model selection for the session, subject to the host's provider access. |
max_session_tokens | positive integer | Cumulative session token budget. Checked between transitions, so usage can exceed it during a model call. |
images | array | Up to 5 image attachments. |
attachments | array | Files as {mimeType, data, filename?, delivery?}. data is base64 without a data-URI prefix. |
A scope can contain several sessions. Sending the same scope_id without session_id starts another conversation. Your application backend must authorize the scope and any requested session before forwarding the call; see Embedding.
Hosted chat does not enforce scope.requireScope from amodal.json. For hosted multi-tenant use, proxy calls through your backend, authorize each scope and session, and retain runtime credentials there.
A scope_id claim on the caller's token overrides the request's scope_id. The runtime then limits that caller's chat resume, session history, and durable session actions to the claimed scope. The direct store API is not partitioned by scope. A hosted browser session without a scope_id claim may request only the agent-level scope or its own user ID; any other scope_id returns 403. Its automations under /api/automations use its own user ID in place of the agent-level scope, which holds the operator's automations. An automation runs with the authority of the user who saved it: an end user's runs get the end-user tool set and restrictions. An end user's automation is disabled at its next run once that user can no longer use the agent.
Attachments support PDF, plain text, Markdown, CSV, PNG, JPEG, GIF, WebP, Office Open XML, OpenDocument, and RTF. Office, OpenDocument, and RTF files are converted to text at ingestion. Each PNG, JPEG, GIF, or WebP image embedded in an Office or OpenDocument file follows its text as a separate image attachment named <filename>/<image>, up to 20 images and 15 MB across all attachments in the request, each image at most 3.75 MiB. The converted text lists the attached images and any embedded media left out, with the reason.
metadata describes where the request came from, such as {"userId": "u_42", "screen": "/billing"}. It accepts at most 50 keys, each up to 64 characters, with string values up to 1,024 characters; a request over these limits is rejected with 400. The runtime records each key on the turn's root span as amodal.meta.<key> and keeps the latest value of each key on the session. The model, tools, prompt templates, and guardrails never see it. Any caller can set any value, so Amodal labels it unverified. The authenticated user, organization, and sign-in method are recorded separately as amodal.user.id, amodal.user.org_id, and amodal.auth.method, and metadata cannot override them. Amodal shows the email of an authenticated user who is an active member with read access to the agent's organization or an active collaborator on the agent. Amodal filters runs, sessions, and feedback by the userId and screen keys. Do not put secrets or sensitive personal data in metadata.
The per-file base64 limit is 28,000,000 characters (about 20 MB decoded), within a 50 MB JSON request limit. There is no separate attachment-count limit. delivery: "inline" sends content to the model; "context" exposes a manifest to the model and keeps the bytes available to tools. When omitted, runtime delivery policy applies.
Sessions
| Method | Path | Purpose |
|---|---|---|
POST | /sessions | Prepare a session before its first message. |
GET | /sessions/history | List sessions, up to 500; accepts ?scope_id=. |
GET | /sessions/history/:id | Read a session's history. |
PATCH | /sessions/history/:id | Update session metadata. |
DELETE | /sessions/history/:id | Delete a session. |
GET | /sessions/pending-approvals | List sessions waiting for an operator answer. |
On a hosted runtime, these routes require authentication. History and pending-approval lists hide sessions owned by another verified user and, for a token with a scope_id claim, sessions in other scopes. Reading, updating, or deleting a hidden session returns 404, the same response as a missing session. Updates and deletions check both live and persisted ownership. Sessions without an owner remain shared, and trusted server credentials without a verified user ID retain access to owned sessions.
For a token without a scope_id claim, the ?scope_id= query is a caller-supplied list filter, not an ownership credential. Lists filter ownership after fetching up to 500 rows, so fewer visible sessions can be returned even when other accessible sessions exist. For tenant-specific rules or calls using server credentials, have your application backend authorize each operation and retain the runtime credential.
Interactions
| Method | Path | Purpose |
|---|---|---|
POST | /chat/sessions/:id/ask-user-response | Answer an ordinary chat confirmation with {correlation_id, approved}. ask_id is also accepted. |
POST | /chat/sessions/:id/messages | Queue {message} for the agent while a turn runs. Returns 202 {status, message_id}, 409 {error: 'no_active_turn'} when no turn is accepting messages, or 422 {error: {code: 'MESSAGE_BLOCKED'}} when a preInput guardrail blocks the message. |
POST | /api/sessions/:id/answer | Answer a parked durable workflow. |
POST | /api/sessions/:id/cancel | Cancel a durable workflow session. |
POST | /api/sessions/:id/restart | Run a durable tool session's tool again in a new session. |
Use the correlation_id from confirmation_required for an ordinary confirmation. The chat loop waits for that response. Choice selections from ask_choice are sent as the next chat message.
A durable_approval_required event identifies a separate parked session. Answer that session through the durable workflow API; see Agent Workflows.
System
| Method | Path | Purpose |
|---|---|---|
GET | /health | Health check |
GET | /api/events | Runtime state subscription, when mounted by the host |
POST | /channels/:channelType/webhook | Inbound messaging-channel webhook |
SSE event types
Chat frames contain a JSON object with type, timestamp, and event-specific fields. The server sends data: frames without a separate event: line.
Common events are listed below. The React SDK exports SSEEvent from @amodalai/react for typed chat stream handling.
| Type | Fields | Purpose |
|---|---|---|
init | session_id | Session created or resumed |
text_delta | content | Incremental answer text |
thinking_delta | content | Model reasoning output, when supplied |
tool_call_start | tool_name, tool_id, parameters, running_label?, completed_label? | Tool execution started |
tool_label_update | tool_id, running_label?, completed_label? | Tool display label changed |
tool_call_result | tool_id, status, result?, content?, parameters?, duration_ms?, error? | Tool result or error |
tool_log | tool_name, message | Handler progress log |
subagent_event | parent_tool_id, agent_name, event_type, tool_name?, tool_args?, result?, text?, error? | Specialist activity |
skill_activated | skill_name | Skill loaded |
widget | widget_type, data | Structured content for the client |
confirmation_required | endpoint, method, reason, escalated, params?, connection_name?, correlation_id? | Ordinary tool approval |
approved / denied | resource_type, preview_id | Approval decision applied |
ask_choice | ask_id, question, options, multi? | Question with selectable options |
durable_approval_required | session_id, tool_name, question?, options?, allow_edits? | Parked workflow approval |
agent_handoff | from, to, removed_tools | Session changed its root agent |
input_redacted | text | User input was redacted |
compaction_start | estimated_tokens, threshold | Context summarization started |
compaction_end | tokens_before, tokens_after, compaction_tokens | Context summarization completed |
warning | message | Non-fatal problem |
error | message | Execution error |
done | reason?, turns?, trace_id?, usage? | Turn completed |
Streaming example
data: {"type":"init","session_id":"session-123","timestamp":"2026-09-07T10:00:00Z"}
data: {"type":"text_delta","content":"Hello.","timestamp":"2026-09-07T10:00:01Z"}
data: {"type":"done","reason":"model_stop","turns":1,"usage":{"input_tokens":100,"output_tokens":2,"cached_tokens":0,"total_tokens":102},"timestamp":"2026-09-07T10:00:01Z"}trace_id is the trace of the run that produced the answer, present when the runtime exports traces. The chat widget sends it with a thumbs up or down, so the feedback links to that run in Amodal.
Done reasons
| Reason | Meaning |
|---|---|
model_stop | The model finished without requesting more tools, or a tool ended execution. |
max_turns | The run reached its model-turn limit, 50 by default. |
user_abort | The request was cancelled or the client disconnected. |
error | An unrecoverable error ended the run. |
budget_exceeded | Accumulated session tokens reached max_session_tokens. The check runs before the run starts and between steps, so one model call can exceed the budget. |
When a top-level run stops on max_turns or budget_exceeded, one final model call streams a summary as ordinary text, and the done event keeps the original reason. For max_turns the summary says what was done, what is left, and what the user should say to continue. For budget_exceeded it says what was done and what is left, and that a new conversation is needed. A tool call requested in that final turn is not run. If the summary call fails or writes no text, the runtime streams a fixed message naming the limit. A run that starts at or over the budget makes no model call. Cancelled runs, subagents, and runs with a turn limit of 1 make no summary call.
Normal completion emits done with usage and the reason. A disconnected client cannot rely on receiving it; read the persisted session history to inspect an interrupted request.
Building a client
streamChat parses the chat stream and accepts an abort signal and runtime token. This helper requires the React package and its peer dependencies; it can be used without rendering a chat component.
import { streamChat } from '@amodalai/react';
async function chat(runtimeUrl: string, token: string, message: string, sessionId?: string) {
for await (const event of streamChat(
runtimeUrl,
{
message,
...(sessionId ? { session_id: sessionId } : {}),
},
undefined,
token,
)) {
if (event.type === 'init') sessionId = event.session_id;
if (event.type === 'text_delta') console.log(event.content);
if (event.type === 'error') throw new Error(event.message);
}
return sessionId;
}Store the returned session ID and pass it with the next message. This minimal example prints text; it does not render widgets or answer confirmations. Use useChat or AmodalChat for those interactions.
Session lifecycle
A request without session_id creates a session. Later requests with that ID resume the conversation. The runtime records the verified user ID of the session's creator and refuses to reuse the session for a request whose verified user ID differs, starting a fresh session instead. A caller with no verified user ID, such as an API key or a service token, can resume any session. When the caller's token has a scope_id claim, the runtime also refuses to resume a session stored in another scope and starts a fresh session instead. Without that claim, the runtime does not compare the session's scope with the request. Your application backend must authorize a resume request before forwarding it.
POST /sessions creates a session ahead of the first message. It accepts the chat body's agent, skill, deploy_id, max_session_tokens, model, scope_id, and context, builds the session from them, and returns 201 with {session_id, idle_ttl_ms}, where idle_ttl_ms is the runtime's idle session timeout in milliseconds. The first message that carries that ID skips the session setup and goes straight to the model. Send the same fields you will chat with, because a live session keeps its skill, deploy, model, and token budget. A prepared session is not persisted until its first turn, and an unused one expires with the idle timeout. The React SDK's chat hooks prepare a session when they mount; see Chat hooks.
The runtime evicts a session from memory after 30 minutes idle. Hosted sessions persist in the platform, so eviction does not delete history and the next request reloads the conversation. Local development keeps sessions and stores in memory only, so they are lost on restart.
An ordinary chat stream disconnect cancels the active request. Sending the same session_id continues the conversation; it does not replay the interrupted SSE response. Use durable workflows when work must continue independently of the connection.
Context compaction
When estimated context passes 90% of the model's context window, the runtime manages history in two ways:
- Tool-result clearing. Once the conversation holds more than 15 tool-result messages, results older than the latest 5 become short summaries or cleared-result markers. Selection is by recency, not size.
- Compaction. The runtime summarizes older turns with the session's model and keeps the latest 6 user turns verbatim. The summary is capped at 4,000 tokens and covers the current task, key data, actions taken, errors, and next steps. It emits
compaction_startandcompaction_end.
Exact wording and tool-result details can be lost. Save facts that must survive in a store. If summarization fails, the run continues with the existing history; after 3 consecutive failures, compaction stops for that run. These thresholds are not configurable from agent source.