Skip to main content

Streaming

GET /v1/ask/:id/stream delivers an agent turn as server-sent events: persisted replay, live frames, keep-alives, terminal close, and a 20-stream workspace cap.

GET /v1/ask/:id/stream streams a turn started with [POST /v1/ask](post-ask) as server-sent events, so an integration can show the answer forming, surface tool activity as it happens, and render text deltas token by token instead of waiting out the poll interval. Streaming is additive: the turn is started and charged once by the submission, the [poll contract](ask-results) is unchanged, and streaming then polling the same turn (or the reverse) never double charges. Use whichever read model fits each consumer; both observe the same turn.

The stream opens by replaying every event the turn has already persisted, then follows with live events as the turn produces them, and closes when the turn's status reaches completed, failed, or awaiting_confirmation. Because the replay is sourced from the durable turn record, a dropped connection loses nothing: reconnect and the full sequence arrives again from the start. If the turn is already settled when you connect, you get the whole recorded sequence and an immediate close, which makes the stream a valid way to fetch a finished turn's event log.

A missing or cross-tenant ask_id is a real 404 issued before any SSE header is sent, never a 200 stream carrying an error frame, so standard HTTP error handling works before you enter stream parsing. The response is Content-Type: text/event-stream; each frame is a data: {json} line followed by a blank line, and a : keep-alive comment line goes out every 25 seconds so proxies and load balancers see traffic on an idle socket. SSE clients ignore comment lines automatically.

GET/v1/ask/:id/stream

Path parameters

id*uuidThe ask_id returned by POST /v1/ask.

Frame kinds

Every frame is a JSON object discriminated by kind. The stream carries the answer and the audit trail of the turn; the model's raw reasoning (thinking.delta) is deliberately withheld from the public stream, because reasoning text is model-version-dependent and not a contract to build on.

Frame kinds

statusframeThe turn's lifecycle status changed. A terminal status (completed, failed, awaiting_confirmation) is followed by the stream closing.
stageframeA loop stage started or completed.
textframeA block of answer text attributed to a stage.
text.deltaframeAn incremental piece of answer text. Concatenate deltas to render the answer as it forms.
tool.callframeThe agent invoked a tool: id, name, impact, title, and the arguments it chose.
tool.progressframeProgress inside a long-running tool: label, optional done/total counters, optional detail.
tool.resultframeA tool finished: ok flag, a one-line summary, and the duration in milliseconds.
confirmation.requiredframeThe turn paused for a confirmation it cannot grant itself. The stream closes with status awaiting_confirmation.
cardframeA generative UI card the turn produced.
artifactframeAn artifact the turn produced: type, id, label, link.
doneframeThe turn finished composing: a headline summary and the token total.
A workspace can hold at most 20 concurrent streams; the 21st connect is refused with 429. The ceiling exists because a held socket is a resource, not a request: close streams you are done with, and fall back to polling for consumers that do not need live frames. Stream connects are metered in their own daily rate-limit namespace, ask_stream, sized above the ask budget so reconnects never compete with submissions.

End to end: scope, stream, then read the citations

The three surfaces compose. Scope an ask to one pipeline so the answer can only draw on that pipeline's documents, hold the stream to watch the turn work, and when the status frame reaches completed, fetch the poll payload once for the structured citations and the verification verdict. The stream is for watching; the poll payload is the settled, citable result.

1. Submit a scoped ask

SUBMIT=$(curl -s -X POST https://api.talonic.com/v1/ask \
  -H "Authorization: Bearer $TALONIC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "question": "Which invoices in this run are overdue, and by how much?",
    "scope": { "pipeline_id": "9e107d9d-372b-4c81-90c3-9dfe9f2c4b6a" }
  }')
ASK_ID=$(echo "$SUBMIT" | jq -r '.ask_id')

2. Stream the turn (curl -N disables buffering)

curl -N -s https://api.talonic.com/v1/ask/$ASK_ID/stream \
  -H "Authorization: Bearer $TALONIC_API_KEY"

data: {"kind":"status","status":"running"}

data: {"kind":"tool.call","id":"t1","name":"query_data","impact":"read","title":"Query overdue invoices","args":{}}

data: {"kind":"tool.result","id":"t1","name":"query_data","ok":true,"summary":"2 rows","durationMs":412}

: keep-alive

data: {"kind":"text.delta","text":"Two invoices are overdue"}

data: {"kind":"done","headline":"2 overdue invoices found"}

data: {"kind":"status","status":"completed"}

3. Read the citations from the poll payload

curl -s https://api.talonic.com/v1/ask/$ASK_ID \
  -H "Authorization: Bearer $TALONIC_API_KEY" \
  | jq '{verdict: .verification.verdict, citations: [.citations[] | {quote, filename, app_url}]}'

Consuming the stream in TypeScript

const res = await fetch(`https://api.talonic.com/v1/ask/${askId}/stream`, {
  headers: { Authorization: `Bearer ${process.env.TALONIC_API_KEY}` },
});
if (res.status === 404) throw new Error('Unknown ask (or another workspace\'s).');
if (res.status === 429) throw new Error('Stream ceiling reached: close a stream or poll instead.');

const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
let answer = '';

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  buffer += decoder.decode(value, { stream: true });
  const frames = buffer.split('\n\n');
  buffer = frames.pop() ?? '';
  for (const frame of frames) {
    if (!frame.startsWith('data: ')) continue; // ": keep-alive" comment lines
    const event = JSON.parse(frame.slice(6));
    if (event.kind === 'text.delta') answer += event.text;
    if (event.kind === 'tool.call') console.log(`tool: ${event.name}`);
    if (event.kind === 'status' && event.status === 'completed') {
      console.log('final answer:', answer);
    }
  }
}

Frequently asked questions

Does streaming cost extra or double-charge a turn?+
No. A turn is charged once, at submission, keyed to the turn id. Opening, dropping, and reopening the stream, or streaming and then polling the same turn, never bills again. Stream connects count against the ask_stream rate-limit namespace, which is a request quota, not a credit charge.
What happens if my connection drops mid-turn?+
Reconnect to the same URL. The stream always starts by replaying every event the turn has persisted, so the full sequence arrives again from the start and you lose nothing. If the turn settled while you were disconnected, you receive the whole recorded sequence and the stream closes immediately.
Why did my stream request return 404 instead of an error frame?+
The turn is resolved before any SSE header is sent, so a missing or cross-tenant ask_id is an honest HTTP 404, never a 200 stream carrying an error frame. Handle it like any other HTTP error, before entering stream parsing.
Why is there no thinking.delta frame?+
The public stream carries the answer and the audit trail: status, stages, text, tool calls, cards, and artifacts. The model's raw reasoning is withheld deliberately, because reasoning text is model-version-dependent and can restate prompt content; it is not a contract for third-party integrations to build on.
How many streams can I hold at once?+
Twenty concurrently held streams per workspace; the next connect is refused with 429. Close streams you no longer need, and use polling for consumers that do not need live frames, since polling has no concurrency ceiling.