oxagenDocs
REST API

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/stream

Use 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

http
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_…"
}
FieldTypeRequiredDescription
contentstringYesThe message text, 1 to 32,768 characters.
conversationIdstringNoThe conversation to continue, by its cnv_ public ID or its UUID. Omit it or send null to start a new conversation.
turnIdstringNoA UUID you create for this turn. Send it if you may want to stop the turn with cancel_assistant_turn.
attachmentsstring[]NoUp to 10 files, by the gen_ IDs that POST /v1/{orgSlug}/{workspaceSlug}/assistant/attachments/upload returned.
goalobjectNoA 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.
tierstringNoThe 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.
modelstringNoExplicit model ID override. It takes precedence over tier.
effortstringNoReasoning 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".
pageContextobjectNoThe app page the person was on. An API caller leaves it out.
activeServerIdsstring[]NoHas 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.
budgetobjectNoHas 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:

StatusCodeCause
400bad_request, attachment_refusedThe body is not valid JSON, a field breaks its rule, or an attached file breaks a rule (reason names it).
401unauthorizedThe key or session is missing, invalid, or expired.
402insufficient_credits, billing_suspended, assistant_spend_cap, workspace_budget_spentA billing or spend limit stops the turn.
403forbiddenThe 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).
404not_foundreason model_tier_not_found: your organization has no tier with the slug in tier.
409conflictThe 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.
429rate_limitedThe 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

TypeFieldsDescription
runrunIdThe run this turn is recorded as. Keep it to read the reply later.
texttextA chunk of streamed assistant text. Concatenate chunks in order.
reasoning-startreasoningIdStart of a reasoning block (sent for models with extended thinking).
reasoning-deltareasoningId, textA chunk of reasoning text.
reasoning-endreasoningId, durationMsThe reasoning block is complete.
step-startstepIndexThe agent is starting a new step: a model call and the tool calls it asks for.
step-finishstepIndexThe step is complete.
tool-input-starttoolCallId, capabilityThe model is streaming tool input, before the tool call runs.
tool-input-deltatoolCallId, deltaA chunk of tool-input text.
tool-call-starttoolCallId, capability, inputPreview, riskLevelThe tool call is about to run. riskLevel on this event is always "low".
tool-call-endtoolCallId, status, durationMs, output?, errorReason?The tool call finished. status is "completed" (with output) or "failed" (with errorReason).
approval-requiredapprovalId, capability, inputPreview, riskLevel, expiresAtA tool call needs a person's approval. The call did not run. See Approvals.
questionid, question, optionsThe agent asked you a question and is waiting for the answer. See Questions.
question-endid, status, reason?The question was answered (status is "answered") or closed with no answer ("closed"). reason says why it closed: "timeout" or "ended".
usageusage.promptTokens, usage.completionTokens, usage.totalTokensToken totals for the whole turn. Sent once, after the model calls finish.
errormessage, 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 the ask_assistant output as JSON: conversationId (UUID), conversationPublicId (cnv_…), userMessageId, assistantMessageId, runId, reply (the whole reply), parkedCards, toolCalls, and stopped.
  • The turn failed. An error event comes first, and the data: line of done is 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:

http
POST /v1/{orgSlug}/{workspaceSlug}/agent/approvals/resolve
Authorization: Bearer ox_…
Content-Type: application/json

{
  "approvalId": "apr_…",
  "decision": "approved"
}
  • decision is "approved" or "denied". An optional note string records why.
  • approvalId takes the apr_ public ID or the UUID that the approval-required event 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.reason approval_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:

http
POST /v1/{orgSlug}/{workspaceSlug}/assistant/question/answer
Authorization: Bearer ox_…
Content-Type: application/json

{
  "questionId": "…",
  "answer": "Staging"
}
  • answer is one of the options or 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 found is true when the waiting question took your answer. It is false when the question was already answered, closed, or belongs to someone else.
  • The stream then sends question-end with status "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:

FieldDescription
promptTokensInput tokens sent to the model
completionTokensOutput tokens generated
totalTokensSum 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]
codeMeaning
engine_unavailableThe assistant engine is down.
assistant_run_not_recordedOxagen could not record the turn as a run, so the assistant did not answer.
engine_abortedThe turn was cancelled before it answered.
model_call_failedThe model provider refused or failed the call.
assistant_model_key_limitThe model key Oxagen holds for your organization reached its daily spend limit.
platform_provider_balanceOxagen's own model provider account ran out of balance. Your organization did nothing wrong.
codegraph_unavailableThe code graph service is not set up or did not answer.
attachment_refusedAn 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_foundThe 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, and connection: keep-alive headers.
  • 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/reply and the body { "runId": "tse_…" }, using the runId from the run event. The response gives runStatus and, once the turn finished, reply.text.
  • To stop a turn on purpose, send a turnId with the request. Then call POST /v1/{orgSlug}/{workspaceSlug}/assistant/turn/cancel with { "turnId": "…" }. The stream ends with a done event whose stopped is true and whose reply holds what the assistant wrote before the stop. The cancel response's found is false when no running turn of yours has that ID.

On this page