Streaming
A long answer that appears all at once after eight seconds feels broken. The same answer appearing word by word feels immediate, even though it finishes at exactly the same moment. Streaming delivers a generative answer as it is produced, and an agent run as it progresses.
for await (const ev of pradra.predictStream(capabilityId, { input: question })) {
if (ev.type === "delta") process.stdout.write(ev.text);
if (ev.type === "done") console.log("\ncost", ev.cost_usd);
}
Agent runs stream the same way with pradra.agents.stream(capabilityId, { goal }).
Streaming is a transport, not a second path
Every gate that applies to the ordinary call applies here, in the same order and from the same code: contract validation, the budget check before the first byte, the content filter, prediction history, metered spend, and the conversation turn. There is one copy of each rule rather than two kept in agreement — a gate cannot hold on one transport and lapse on the other.
An exhausted budget therefore arrives as an error event with code
RATE_LIMITED and zero text.
The events
| Event | Meaning |
|---|---|
state | Phase changed: retrieving, calling_tool, generating. |
delta | A chunk of answer text. Concatenating every delta is the answer. |
tool_call | The agent is calling a tool (name only). |
tool_result | That call finished: name, ok/failed, duration. |
approval_required | The run parked on a consequential call — with the exact arguments. |
done | Terminal success: usage, cost, ids, content_filtered. |
error | Terminal failure, carrying the same code the JSON endpoint would return. |
New event types may be added; ignore ones you do not recognize rather than treating them as errors.
Why tool_result carries no payload
A tool result can be large, and it can hold data that must not reach the person
watching the stream — the stream may end up rendered in your customer's
browser. So the outcome is reported and the payload is not sent. tool_call
carries only the tool name for the same reason.
approval_required is the deliberate exception: it does carry the proposed
arguments, because an approver has to see exactly what will execute.
What you receive is what is stored
The content filter runs on the flow, not only on the finished text: a small tail is held back so a forbidden pattern split across two chunks is still caught before release. If it fires mid-answer, the already-sent text stands, the rest is withheld, and the fixed replacement is appended — and what the transcript stores is exactly the deltas you received. Bytes cannot be un-sent, so the guarantee that is actually worth making is that the audit matches what the user saw.
done.content_filtered: true marks it. Treat it as information, not as an
error: it is the safety control working.
Agents stream progress, not thinking
An agent's model turns are structured actions, not prose. Streaming those
tokens would leak the control-plane protocol to your end user and show them
half-formed tool arguments as if they were an answer. So an agent streams
state, tool_call and tool_result while it works, then its finalized
answer as a single delta.
When the client hangs up
The two paths differ on purpose:
- A generative stream stops. Nobody is reading, so nothing should keep being generated or billed.
- An agent run does not. Its trace is run state, and a write that already fired cannot be un-fired by closing a socket. The run finishes and is recorded; what a disconnect costs is the answer, which you collect from the run afterwards.
A run parked for approval closes the stream cleanly rather than holding it open. A park can wait a day for a human, and a socket held that long is a leak, not a stream.
If you write your own client
- Buffer to a blank line. Frames may be split anywhere across network chunks, including mid-character. Parsing each chunk as it arrives is the classic way to lose the last part of an answer.
- Hang up by cancelling the reader, not by leaving the loop. A reader already waiting on the network stays parked until the server sends something — a stop built on the loop alone appears to do nothing on a stream that is thinking.
- Failures before the stream opens are ordinary HTTP errors. A malformed
request answers with a normal status code; only after the response has begun
do failures arrive as an
errorevent. - If you sit behind a proxy, disable response buffering for these routes. Otherwise every delta is held until the response ends — correct code, and the answer still appears all at once.
In the Studio
The Chat page streams both kinds of turn, shows the tool trail as it happens, and reports usage and cost when the turn completes.
Next
- Give the stream memory: Conversations.
- The non-streaming equivalent: Real-time predictions.
- Consume it from code: Conversations & streaming.