Skip to main content

Chat & citations

One call runs the whole turn: the agent retrieves, calls tools, and answers. You choose blocking or streaming.

Blocking

const res = await ai.chat.send({
message: "How did Q3 revenue trend?",
tags: ["finance"],
});
FieldWhat it holds
session_idpass it back to continue the conversation
contentthe answer
citationseverything the turn was shown that a sentence can point at, numbered — { marker, kind, title, quote, url, used } and the extras for its kind
attachmentsnon-text outputs: generated media, files exported from a sandbox
guard_flagsguardrails that acted this turn ([] normally)
requires_action / tool_callsthe agent is waiting on your tools — see Tools
finish_reasonnull normally; see Limits & errors

Streaming

Streaming is Server-Sent Events over plain fetch — no WebSocket, no proxy.

const handle = ai.chat.stream(
{ message: input, tags: ["finance"] },
{
onToken: (delta, full) => render(full),
onCitations: (cs) => showSources(cs),
onToolStart: (name) => setStatus(`Running ${name}`),
onAttachments: (files) => offerDownloads(files),
},
);

const done = await handle.done; // the terminal payload

The handle is itself awaitable, and gives you disconnect() (stop listening, generation continues), cancel() (stop server-side generation too, which stops model spend), and runId().

runId() returns null until the server's first frame arrives, so reading it on the line after stream() logs null. Read it inside a handler, or after awaiting:

const handle = ai.chat.stream({ message }, { onEvent: () => log(handle.runId()) });
await handle.done;
handle.runId(); // always set by here

Events

Every frame the server can send, and the handler it reaches. onEvent receives all of them, typed, if you'd rather switch on it yourself — which is also what you'd implement against if you're writing a client in another language.

EventPayloadHandler
run{ run_id }— (also on the X-Run-Id header)
start{ session_id }onEvent
token{ delta }onToken(delta, full)
reasoning{ delta }onReasoning(delta, full) — arrives before and between token frames
tool_start / tool_end{ name, input } / { name, output }onToolStart / onToolEnd
command_output{ command_id, command, stream, delta }onCommandOutput — a sandbox command's output while it runs
command_finished{ job_id, command, exit_code, status }onCommandFinished — a background command reported back at a step boundary
citations{ citations }onCitations
attachments{ attachments }onAttachments — every file so far, not just the new one
todos{ todos }onTodos — the plan, as it's written and ticked off
title{ title }onTitle — the conversation was just named, mid-turn by default
subagent / subagentsone subagent / all of themonSubagents (both frames)
questions{ questions }onQuestions to render, onQuestion to answer and resume — the turn is paused until you do
approvals{ approvals }onApprovalnothing proceeds until you decide
uia component renderonUi
browser_handoffa hand-offonBrowserHandoff
contextprogress, then a reportonContextcompaction, which happens before the turn produces anything
guardrail{ stage, flags, reason?, content? }onGuardrail(stage, flags, content)three positional arguments, not the frame object
donethe terminal payloadresolves handle.done
cancelled / error{ run_id } / { detail, code?, run_id?, session_id? }rejects handle.done

Frames that are only a comment (: keepalive) are not events. The server sends one every 15 seconds while a turn is thinking rather than talking — a reasoning model can go minutes between frames, and to anything with an idle read timeout that is indistinguishable from a dead connection. The SDK drops them; a client you write yourself should too.

Three of these mean the turn has stopped and is waiting on you: questions, approvals and browser_handoff. All three also come back on the blocking /chat response with requires_action, and all three arrive with an empty tool_calls — a question is not work for your client to run, so tool auto-dispatch can never answer one by accident.

The done payload carries the final state of everything above (content, citations, attachments, todos, subagents, questions, approvals, ui, handoff, guard_flags, context, reasoning, finish_reason), so a client that ignored the incremental frames still ends up with the whole turn.

Dropped connections resume

Generation runs independently of your connection. Every frame carries an id and the response carries X-Run-Id, so the SDK reconnects to the same run and replays what you missed — a closed laptop lid mid-answer doesn't lose the answer, and doesn't pay for it twice. onReconnect(attempt) fires each time it re-attaches; maxRetries defaults to 10.

Because generation is decoupled, disconnect() then reconnecting later is a legitimate pattern, and cancel() is what you call when the user really means stop.

What cancel guarantees

cancel() stops generation, and the request it stopped is not carried out later. That second half is worth stating because the alternative is a real hazard: the cancelled turn's message stays in the conversation (the user typed it; the transcript should show it), and a message nothing has answered reads to a model as work still outstanding. So a cancelled "close every berth for July", followed a minute later by "what time is it?", used to get both. It is now marked unanswered at the start of the next turn and never replayed — it stays visible in sessions/{id}/messages with extra.turn_abandoned: true, and the model does not see it.

The same applies to a turn that ended without an answer for any other reason — the provider hung up, the server was replaced mid-turn. Nothing was answered, so nothing is resumed; send it again if you still want it.

One thing cancel cannot do: stop a blocking chat.send(). It holds no resumable run to cancel, and closing the HTTP request does not stop generation either, so /chat/stream/{run_id}/cancel answers 409 rather than reporting a turn stopped that is still spending. Use chat.stream() for anything a user may want to stop.

How long a run lasts

Worth knowing exactly, because a turn that thinks for minutes is a turn where this matters:

while generatingas long as the turn takes. No ceiling — an eighteen-minute turn is resumable for eighteen minutes.
after it finishesits frames stay replayable for 10 minutes, so a reconnect a moment late still replays the whole turn. Eviction is lazy, so a quiet deployment sometimes keeps them longer — treat 10 minutes as the guarantee, not the limit.
a restart of the API processends every run it held. That is a deploy, a crash, or an OOM.

The frames are what expires; the run is not. A reconnect to a run whose frames are gone answers 200 with one terminal error frame — not a 404 — and that frame names the conversation.

A run id is not a capability. Attaching, steering and cancelling all check the same per-subject ACL that sessions.messages checks, on the same conversation — so a token that could not read the transcript cannot read the stream, cannot steer a message into the turn, and cannot stop it, whatever run id it holds. Steer and cancel are checked as writes, so a conversation another user may read is still not one they may interrupt. This matters because run ids travel: a 409 for a racing turn names the run in flight, resume errors quote one, and anything that logs them puts them somewhere else.

A 404 on a reconnect means one of two things and says so without distinguishing them: nobody has a record of that run id, or it belongs to a conversation this token may not read (the same 404-not-403 rule as everywhere else — probing ids must reveal nothing). It will never tell you the session is gone, because on a resume that would be the one wrong thing to say: the answer is usually sitting in the transcript. handle.done rejects with an AgentStreamError carrying the details:

try {
await ai.chat.attach(runId, { onToken: render });
} catch (e) {
if (e instanceof AgentStreamError) {
e.code; // "stream_expired" | "stream_lost" | "stream_elsewhere" | ...
e.sessionId; // which conversation — set from the turn's FIRST frame
e.content; // the text that had streamed. Render it; the user watched it arrive
}
}
codeWhat happenedWhat to do
stream_expiredthe turn finished; its frames aged outsessions.messages(e.sessionId) — the answer is there
stream_lostthe process generating it restarted, so it never finishedsend the turn again
stream_cancelled / stream_failedcancelled, or failede.message carries the server's own words
stream_elsewhererecorded as running, but not on the worker that answeredretry; do not resend

sessionId is the point of all this. It used to be the thing a broken stream took with it: on a first turn the conversation is created inside the turn, so the stream is the only place its id has ever appeared — and a 404 on the reconnect left a finished answer sitting in a conversation the client could not name. It is now on the handle from the start frame onward, before the first token:

const handle = ai.chat.stream({ message: "..." }, { onToken: render });
handle.sessionId(); // known from the first frame, not just from `done`

A runId nothing has any record of is still a 404. That one really is a bad id.

Re-attaching after a page reload

The paragraph above covers a dropped socket, which the handle recovers by itself. It does not cover a reload: a new page has no handle, and the assistant's reply is not stored until the turn ends — so sessions.messages returns the question and an empty answer while generation is still going.

Ask the session what is running on it, and attach:

const { run_id, last_event_id } = await ai.chat.sessions.activeRun(sessionId);
if (run_id) {
const handle = ai.chat.attach(run_id, { onToken: (_d, full) => render(full) });
const done = await handle.done;
}

run_id is null when nothing is running, and status says what that null means: idle (the last turn ended normally), lost (a turn was generating and the process running it went away — nothing was persisted, so send it again), elsewhere (running, but not on the worker that answered — retry), or running alongside a run_id. lost was previously indistinguishable from idle, which is how a ten-minute answer became a blank bubble nobody thought to retry.

authoritative says whether that null is the whole truth. A run lives in the memory of the API process that started it, so a deployment running several workers can only answer for the one that took your request; authoritative: false means "no run here, possibly one elsewhere". It is true on a single-worker deployment, which is the common one. attach gives you the same StreamHandlecancel, steer, disconnect and done all work as if this client had started the turn — and replays from the beginning by default, which is what a fresh page wants; pass { lastEventId } to receive only what you have not rendered.

You do not have to persist the run id for this. That matters: a client that keeps it in memory loses it on the reload, and one that keeps it in storage still cannot help a user who opens the same conversation on another device.

Two limits worth knowing

Client tools are not auto-executed on a re-attached stream — this client did not start the turn, so a pause arrives as a done with requires_action and you answer it with chat.send({ session_id, tool_results }).

And a run lives in the memory of the API process that started it, so activeRun answers for that process — check authoritative before treating a null as "the turn finished". This is the same constraint the underlying resume endpoint has always had, and it is why a run id is not a durable handle. If you scale the API out, route by run id (or pin a session to a worker) so re-attachment keeps working.

Reasoning models

A reasoning model thinks before it answers, and that phase can take a while. Its thinking streams on its own channel, so the wait is visible instead of looking like a hang:

ai.chat.stream({ message, model: "openai/o4-mini", reasoning_effort: "medium" }, {
onReasoning: (_delta, full) => showThinking(full), // or just setStatus("Thinking…")
onToken: (_delta, full) => render(full),
});

reasoning frames arrive before — and between — token frames. Two ways to use them: render the trace as it streams, or ignore the content and treat the first frame as your "thinking" indicator. Either way you have something to show during a phase that would otherwise be silent.

Blocking callers get the whole trace at once:

const res = await ai.chat.send({ message, reasoning_effort: "high" });
res.reasoning; // the thinking, or "" for a non-reasoning model
res.content; // the answer — thinking never leaks into this

reasoning_effort is minimal | low | medium | high, clamped to the token's ceiling and dropped for models that don't support it — including a model that takes the parameter but not the value, since the vocabulary is the provider's (a model offering xhigh | medium | low refuses minimal). The turn is answered without it rather than failing, so a token minted with maxEffort: "minimal" is a cheaper tier and never a broken one. Thinking is not stored: it won't appear in session history, and it is never sent back to the model on a later turn (providers don't accept their own thinking back, and it would cost context for nothing). Persist it yourself if you need an audit trail.

Grounding

Everything the turn was shown is numbered, whatever it came from. A document passage, a web result, a page the agent opened, something it remembered, a wiki page, a result from one of your own systems, a port it exposed — one list, one sequence of numbers, and the agent closes a grounded sentence with the number it used:

res.content; // "There are 5 free days at destination [2]."
res.claims; // [{ text: "There are 5 free days…", start, end, citations: [2] }]
res.citations[1]; // { marker: 2, kind: "document", title, quote, used: true, page, score }
res.attribution; // "per-claim" | "retrieval-only" | "none"

kind is document · web · memory · wiki · tool · preview · file. Render title, quote and url; branch on kind only for the extras you want — a page number for a document, the tool's name for a result, "you told me this" for a memory, the path for a file.

kind describes the evidence, not the tool: a page the agent fetched with browse_url and one it drove to with the browser are both web, because what the reader checks is the same URL either way.

What is citable, and what is not

The rule is the one this page opened with — a citation is something anyone can go and check — applied literally:

a file the agent read in its sandboxkind: "file", title is the path. Citable: you can ask for that file, and the agent can send it
a page it browsed or fetchedkind: "web", with the URL
command outputnot citable. It is gone once it scrolls past, so there is nothing for a reader to look at — and a twenty-step loop would bury the register in noise

If a computation matters, the agent is told to write it to a file and read the file back — which makes it citable under the first rule rather than needing a special case. This matters for the answers that are most worth checking: an audit whose number comes out of arithmetic done in a sandbox can now point at the file it computed from, where before it could only say "according to /workspace/fact.txt" in prose. A parenthetical and a pill look the same to a reader and only one of them is checkable.

This used to be two arrays and only one of them could be footnoted: citations held document passages, and everything else went to a sources list no claim could point at. So an answer built on a web page, on your MCP server, or on what the user told you last week came back with attribution: "none" and nothing a reader could follow — on a product whose pitch is an agent that does all four. There is one array now.

claims is what answers "where did that sentence come from", and used says which of the register did any work. Check attribution: retrieval-only means the model attributed nothing this turn, so you have what was looked at and no per-sentence mapping, which is a different thing to draw.

Rendering the pills

renderCited gives you the answer already split into the segments a pill sits at the end of, so you are not doing offset arithmetic:

import { renderCited } from "@oberik/sdk";

{renderCited(res).map((seg, i) => (
<span key={i}>
{seg.text}
{seg.citations.map((c) => <Pill key={c.marker} citation={c} />)}
</span>
))}

Every segment comes back in order, attributed or not, and concatenating text reproduces content exactly — so an unattributed answer is one segment with no pills and needs no special case. A marker the model invented resolves to nothing and is dropped rather than rendered as a pill the reader would click.

To keep [2] out of the visible prose and place footnotes yourself from claims[].start/end:

curl -sX PUT "$CP/api/projects/$OBERIK_PROJECT_ID/citations" \
-H "X-API-Key: $OBERIK_PROJECT_KEY" -H "content-type: application/json" \
-d '{"markers":"stripped"}'

Every entry carries a title and a quote, so your UI can show the reader what to check. Scope a turn to a subset of the project's data:

await ai.chat.send({
message: "What's our refund policy?",
tags: ["policies"], // only documents with these tags
// document_ids: ["…"], // or exactly these documents
});

Scoping this way also keeps the turn on your data: web search is switched off automatically when a request names tags or document_ids.

Choosing what the agent may do this turn

Every capability the token grants can be declined per request — useful for a cheap "just answer" path, or a user-facing toggle.

await ai.chat.send({
message,
enable_web_search: false, // don't leave our data
enable_computer: false, // no sandbox this turn
allowed_tools: ["rag_search"], // exactly one tool
});

The effective set is always project ceiling ∩ token capabilities ∩ this request. Naming a tool the token doesn't carry never grants it.

Every switch, all defaulting to true:

enable_rag · enable_memory · enable_web_search · enable_browser · enable_todo · enable_computer · enable_subagents · enable_scheduling · enable_ask_user · enable_approvals · enable_plugins · enable_action_space

Two of them are worth turning off deliberately rather than leaving on: enable_ask_user and enable_approvals stop a turn dead until a person answers, which is right in a chat window and wrong in a batch job, a webhook or a scheduled run — where nobody is watching and the turn simply waits forever. Scheduled tasks never bind them for exactly this reason.

The capability each switch corresponds to is in the capability table.

Models

Omit model and the project default applies. Pass one to override, bounded by the token's allow-list:

await ai.chat.send({ message, model: "openai/gpt-4o", reasoning_effort: "low" });

reasoning_effort is minimal | low | medium | high, clamped to the token's ceiling and dropped for models that don't support it — including a model that takes the parameter but not the value, since the vocabulary is the provider's (a model offering xhigh | medium | low refuses minimal). The turn is answered without it rather than failing, so a token minted with maxEffort: "minimal" is a cheaper tier and never a broken one. temperature defaults to 0.2.

System prompts

Two can apply, and they don't have equal standing.

The project's (dashboard → Capabilities → System prompt) goes into every request for that project, first, under a heading marking it authoritative. It's where the persona, tone and refusal rules belong.

Yours, per request, is additive and needs the system_prompt capability:

await ai.chat.send({ message, system_prompt: "Answer in British English." });

Without that capability the request is refused with 403 rather than having the prompt quietly dropped — a token that can't set one may send user messages only. It's appended after the project's, labelled as additional, with an explicit instruction that the project's wins on a conflict.

Worth being straight about the limit: ordering and labelling is instruction, not enforcement — a model can still be talked around. Anything that must hold belongs in a capability, an allowed_tools list or a guardrail, which is why the capability gate exists rather than trusting the wording.

Sessions

Pass session_id to continue. History includes images from earlier turns, so "make that chart green instead" works without re-sending anything.

const first = await ai.chat.send({ message: "Draft a summary" });
await ai.chat.send({ session_id: first.session_id, message: "Shorter, and mention EMEA." });
const page = await ai.chat.sessions.list(userRef); // userRef is your own label, optional
page.items; // the conversations
page.has_more; // is there another page?
page.next_offset; // pass as `offset` to get it — null when this is the last

await ai.chat.sessions.messages(sessionId); // full transcript
await ai.chat.sessions.fork(sessionId, { upToMessageId }); // branch a conversation
await ai.chat.sessions.delete(sessionId);

list is a page, not the set: the default limit is 100 and the cap is 500. It used to return a bare array, so a project with 137 conversations answered with 100 rows and nothing saying so — indistinguishable from a project that has exactly 100. Read has_more, and page with next_offset rather than your own arithmetic (limit is clamped server-side, so offset + yourLimit can be wrong).

Forking is how you build "edit this message and try again" without destroying the original thread. Sessions are owned by the token's subject: one end-user can never list another's.

While a turn is running

A long turn is not a closed door. With the steer capability a user can send a message into it and the agent picks it up at its next step, and several messages typed while it works can be sent together as separate messages with one reply. Once it ends, you can offer suggested follow-ups and — for someone coming back later — a one-line recap.

All of that is Working with the user, along with the plans the agent keeps and the questions it can ask.

One conversation runs one turn at a time. Starting a second while the first is still generating answers 409, naming the run in flight:

409 a turn is already running on this conversation (run 9f3c…). Starting a second one
would interleave the two in the transcript, and nothing stored says which answer
belongs to which question. Attach to it with GET /chat/stream/9f3c… …

That is almost always what the second caller wanted anyway — a retry, a second tab, a phone resuming while the desktop submits. Attach to the run and you get the answer that is already being written; start a second turn and the stored conversation stops alternating (user, user, assistant, assistant), nothing pairs an answer with its question, and every later turn replays a transcript some providers refuse. To add something to a turn in flight, use steer.

A turn that ended does not wedge its conversation, however it ended. One that failed at the provider — a rotated key, a rate limit, a model outage — is finished, and the next turn proceeds normally; one whose process died is reported as lost rather than running. And GET /chat/sessions/{id}/run answers from the same place this 409 does, so if it says idle, posting will not be refused. (It did not always: a turn that failed at the provider reported the failure and then left its conversation refusing every later turn, while /run reported that same conversation idle. Two doors, opposite answers — OBE-115.)

Very long conversations eventually outgrow the model's window; what happens then is Context management, and it is a project setting rather than something each request decides.