Chat streaming
Stream one turn of the in-app agent over SSE: request fields, event types, the end of the stream, approvals, and connection handling.
The chat stream runs one turn of the in-app agent, the ask_assistant capability, and sends it back as Server-Sent Events (SSE). The Oxagen app's assistant reads this same route.
To get the whole reply as one JSON response instead, call POST /v1/{orgSlug}/{workspaceSlug}/assistant/ask. It takes content, conversationId, turnId, attachments, and goal, and returns the same object the stream's done event carries. It refuses the model override fields.
Endpoint
POST https://api.oxagen.sh/v1/{orgSlug}/{workspaceSlug}/chat/streamUse the org and workspace your API key belongs to. A path that names another org or workspace gets 403 with error.reason key_scope_mismatch.
The turn acts for a person: the signed-in user, or the person who created the API key. The caller needs a role ask_assistant grants: organization Owner, or workspace Admin or Member. A workspace API key acts as a Member, so it can ask.
Request
POST /v1/acme-corp/engineering/chat/stream
Authorization: Bearer ox_…
Content-Type: application/json
{
"content": "Summarize the last 10 agent runs and identify any failures.",
"conversationId": "cnv_…"
}| Field | Type | Required | Description |
|---|---|---|---|
content | string | Yes | The message text, 1 to 32,768 characters. |
conversationId | string | No | The conversation to continue, by its cnv_ public ID or its UUID. Omit it or send null to start a new conversation. |
turnId | string | No | A UUID you create for this turn. Send it if you may want to stop the turn with cancel_assistant_turn. |
attachments | string[] | No | Up to 10 files, by the gen_ IDs that POST /v1/{orgSlug}/{workspaceSlug}/assistant/attachments/upload returned. |
goal | object | No | A condition the result must meet: statement (up to 2,000 characters) and maxRounds (1 to 4, default 3). An independent check judges each round against it, and the turn keeps working until the goal is met or the rounds run out. |
tier | string | No | The slug of one of your organization's text model tiers, such as "balanced". The turn runs on that tier's stella model, and the tier's shared-key setting decides whether Oxagen's key or your organization's own key pays. Omit it to run on your organization's default tier. "fast", "balanced" and "precise" still select Oxagen's tier of that name when no tier of yours has the slug. |
model | string | No | Explicit model ID override. It takes precedence over tier. |
effort | string | No | Reasoning effort: "low", "medium", "high", "xhigh" or "max". The turn sends it only to a model that takes an effort level, and OpenAI models top out at "high". |
pageContext | object | No | The app page the person was on. An API caller leaves it out. |
activeServerIds | string[] | No | Has no effect. stella's tools are Oxagen's capabilities, and a turn loads no workspace MCP server. The field is accepted so an older client is not refused. |
budget | object | No | Has no effect. No budget you configure applies to the assistant. The field is accepted so an older client is not refused. |
Refusals before the stream opens
The route checks the request, the caller's role, and billing before it opens the stream. A refusal at that point is not a stream. It is a JSON error with the same status and body as POST /assistant/ask:
| Status | Code | Cause |
|---|---|---|
| 400 | bad_request, attachment_refused | The body is not valid JSON, a field breaks its rule, or an attached file breaks a rule (reason names it). |
| 401 | unauthorized | The key or session is missing, invalid, or expired. |
| 402 | insufficient_credits, billing_suspended, assistant_spend_cap, workspace_budget_spent | A billing or spend limit stops the turn. |
| 403 | forbidden | The caller may not ask. reason says why: for example no_principal (the request resolves to no person) or kill_switch (an operator stopped the assistant). |
| 404 | not_found | reason model_tier_not_found: your organization has no tier with the slug in tier. |
| 409 | conflict | The tier cannot run the turn. reason says why: model_tier_key_missing (the tier pays with your organization's own model key and none is set; add one on the Model funding page or turn on the tier's shared key), model_tier_key_cannot_serve (your organization's key cannot run the tier's model; turn on the tier's shared key or set the tier to a model the key can run), model_tier_archived, model_tier_unmapped (the tier has no stella model) or model_family_unknown. |
| 429 | rate_limited | The workspace sent too many chat requests. This body is { "error": "rate_limited" }. Wait for the number of seconds in Retry-After. |
Response
The response has Content-Type: text/event-stream. Each event is a JSON object on a data: line, followed by a blank line. The stream starts with run and sends usage last, just before done.
data: {"type":"run","runId":"tse_…"}
data: {"type":"step-start","stepIndex":0}
data: {"type":"tool-call-start","toolCallId":"call_1","capability":"list_runs","inputPreview":{"limit":10},"riskLevel":"low"}
data: {"type":"tool-call-end","toolCallId":"call_1","status":"completed","output":{"runs":[...]},"durationMs":142}
data: {"type":"text","text":"Here are the last 10 agent runs:"}
data: {"type":"step-finish","stepIndex":0}
data: {"type":"usage","usage":{"promptTokens":1240,"completionTokens":312,"totalTokens":1552}}
event: done
data: {"conversationId":"…","conversationPublicId":"cnv_…","userMessageId":"…","assistantMessageId":"…","runId":"tse_…","reply":"Here are the last 10 agent runs: …","parkedCards":[],"toolCalls":[…],"stopped":false}Event types
| Type | Fields | Description |
|---|---|---|
run | runId | The run this turn is recorded as. Keep it to read the reply later. |
text | text | A chunk of streamed assistant text. Concatenate chunks in order. |
reasoning-start | reasoningId | Start of a reasoning block (sent for models with extended thinking). |
reasoning-delta | reasoningId, text | A chunk of reasoning text. |
reasoning-end | reasoningId, durationMs | The reasoning block is complete. |
step-start | stepIndex | The agent is starting a new step: a model call and the tool calls it asks for. |
step-finish | stepIndex | The step is complete. |
tool-input-start | toolCallId, capability | The model is streaming tool input, before the tool call runs. |
tool-input-delta | toolCallId, delta | A chunk of tool-input text. |
tool-call-start | toolCallId, capability, inputPreview, riskLevel | The tool call is about to run. riskLevel on this event is always "low". |
tool-call-end | toolCallId, status, durationMs, output?, errorReason? | The tool call finished. status is "completed" (with output) or "failed" (with errorReason). |
approval-required | approvalId, capability, inputPreview, riskLevel, expiresAt | A tool call needs a person's approval. The call did not run. See Approvals. |
question | id, question, options | The agent asked you a question and is waiting for the answer. See Questions. |
question-end | id, status, reason? | The question was answered (status is "answered") or closed with no answer ("closed"). reason says why it closed: "timeout" or "ended". |
usage | usage.promptTokens, usage.completionTokens, usage.totalTokens | Token totals for the whole turn. Sent once, after the model calls finish. |
error | message, code?, reason? | The turn failed after the stream opened. See Errors. |
End of the stream
The stream always ends with a named SSE event, done:
- The turn finished. The
data:line is theask_assistantoutput as JSON:conversationId(UUID),conversationPublicId(cnv_…),userMessageId,assistantMessageId,runId,reply(the whole reply),parkedCards,toolCalls, andstopped. - The turn failed. An
errorevent comes first, and thedata:line ofdoneis the text[DONE].
event: done
data: [DONE]Each entry in toolCalls gives toolCallId, toolName, outcome (completed, failed, denied, cancelled, or parked), durationMs, and approvalId (set only on a parked call).
Approvals
A tool call that needs a person's approval does not run, and the turn does not wait for it. The stream sends approval-required, the turn goes on, and the done event lists every parked call in parkedCards. Each card gives approvalId (apr_…), capability, and expiresAt.
Answer each one before its expiresAt:
POST /v1/{orgSlug}/{workspaceSlug}/agent/approvals/resolve
Authorization: Bearer ox_…
Content-Type: application/json
{
"approvalId": "apr_…",
"decision": "approved"
}decisionis"approved"or"denied". An optionalnotestring records why.approvalIdtakes theapr_public ID or the UUID that theapproval-requiredevent carried.- Only the person who asked the turn can answer its approvals. For a turn asked with an API key, that person is the key's creator.
- An approval that expired, was already answered, or belongs to someone else gets 409 with
error.reasonapproval_expired.
When you approve, Oxagen runs the parked call as a new run. The response's execution field says what happened: status (succeeded, dispatched, failed, indeterminate, queued, denied, or expired), the runId of the new run, and a reason when the call failed.
Questions
The agent can stop and ask you a question when it cannot go on without your choice. The stream sends question with the question's id, its text, and the options the agent offered, which may be empty. The turn waits for your answer.
Answer it with the id:
POST /v1/{orgSlug}/{workspaceSlug}/assistant/question/answer
Authorization: Bearer ox_…
Content-Type: application/json
{
"questionId": "…",
"answer": "Staging"
}answeris one of theoptionsor your own words, up to 4,000 characters.- Only the person who asked the turn can answer. For a turn asked with an API key, that person is the key's creator.
- The response's
foundistruewhen the waiting question took your answer. It isfalsewhen the question was already answered, closed, or belongs to someone else. - The stream then sends
question-endwithstatus"answered", and the turn goes on with your answer.
A question closes with no answer when nobody answers within five minutes, when you stop the turn, or when the turn ends. The stream sends question-end with status "closed". After a timeout, the agent goes on without the answer and says what it assumed.
Message threading
Pass conversationId to continue an existing thread. The route loads the conversation's history for you, so you do not pass message IDs to keep it. The model reads up to the last 50 messages word for word. When a thread grows past that, Oxagen summarises the older messages into one note and sends it ahead of them, so facts from early in the thread still reach the model. The summary is stored with the conversation and rewritten about once every five turns. The run for each turn records whether it carried a summary.
Omit conversationId to start a new conversation. Read conversationPublicId from the first turn's done event and send it with later messages in the same thread.
Token usage
The stream sends one usage event after the model calls finish. It holds the token counts for the whole turn under a usage object:
| Field | Description |
|---|---|
promptTokens | Input tokens sent to the model |
completionTokens | Output tokens generated |
totalTokens | Sum of prompt and completion tokens |
Errors
A failure after the stream opened arrives as one error event, then done with [DONE]:
data: {"type":"error","message":"the assistant engine is unavailable","code":"engine_unavailable"}
event: done
data: [DONE]code | Meaning |
|---|---|
engine_unavailable | The assistant engine is down. |
assistant_run_not_recorded | Oxagen could not record the turn as a run, so the assistant did not answer. |
engine_aborted | The turn was cancelled before it answered. |
model_call_failed | The model provider refused or failed the call. |
assistant_model_key_limit | The model key Oxagen holds for your organization reached its daily spend limit. |
platform_provider_balance | Oxagen's own model provider account ran out of balance. Your organization did nothing wrong. |
codegraph_unavailable | The code graph service is not set up or did not answer. |
attachment_refused | An attached file broke a rule. reason names the rule. A file can be refused before the stream opens, as a 400, or while the turn reads it, as this event. |
conversation_not_found | The conversationId does not name a conversation in this workspace. |
An error without a stable code has the message "Stream error" and no code. To try again, send the next message with the same conversationId.
Connection handling
- The response sets the
content-type: text/event-stream,cache-control: no-cache, andconnection: keep-aliveheaders. - While the turn is quiet, the stream writes an SSE comment line,
: keep-alive, every 15 seconds. SSE readers skip comment lines. - A turn with many steps can run for several minutes. Use an idle timeout longer than 15 seconds instead of a short total timeout.
- Closing the connection does not stop the turn. It runs to the end and saves its reply. Read the finished reply with
POST /v1/{orgSlug}/{workspaceSlug}/assistant/replyand the body{ "runId": "tse_…" }, using therunIdfrom therunevent. The response givesrunStatusand, once the turn finished,reply.text. - To stop a turn on purpose, send a
turnIdwith the request. Then callPOST /v1/{orgSlug}/{workspaceSlug}/assistant/turn/cancelwith{ "turnId": "…" }. The stream ends with adoneevent whosestoppedistrueand whosereplyholds what the assistant wrote before the stop. The cancel response'sfoundisfalsewhen no running turn of yours has that ID.