Streaming
Token-by-token output from an object that is both an async iterable and a promise.
agent.stream() runs the agent and reports progress as it happens. What it
returns is an async iterable of events that is also awaitable
for the final RunResult:
const stream = agent.stream('Write a short story about Paris.')
for await (const event of stream) {
if (event.type === 'text.delta') process.stdout.write(event.delta)
}
const result = await stream // the same run, now finished
console.log(`\n${result.usage.outputTokens} tokens`)Both halves are optional. Awaiting without iterating is an ordinary run() that
happens to buffer its events; iterating without awaiting is a pure UI feed.
Just the text
Most UIs only want the tokens, so there is a shortcut:
for await (const chunk of agent.stream(prompt).textStream()) {
ui.append(chunk)
}textStream() consumes the same single iteration as the event stream — use one
or the other, not both.
Streaming to a browser
toResponse() is the whole route handler:
export async function POST(req: Request) {
const { message, userId } = await req.json()
return agent.stream(message, { sessionId: userId }).toResponse()
}That works in Next.js, Hono, Bun, Deno, and Cloudflare Workers, because
toTextStream() returns a standard web ReadableStream and toResponse() wraps
it in a standard Response. Sensible headers come with it —
text/plain; charset=utf-8, cache-control: no-store, x-accel-buffering: no
so a proxy does not swallow the stream, and x-run-id for correlating a trace.
Anything you pass in init wins.
Cancelling the response body aborts the run. A browser closing the tab should not leave a model call running and billing.
In Node
node:http predates Response, so bridge the body once at the edge:
import { Readable } from 'node:stream'
Readable.fromWeb(agent.stream(prompt).toTextStream()).pipe(process.stdout)The SDK does not do this for you: importing node:stream into the package root
would break the guarantee that it runs on an edge runtime, and a web
ReadableStream already works everywhere including Node.
Events, not just text
Text alone cannot render "calling get_weather…". toEventResponse() sends
every event as text/event-stream, and readEventStream() reads it back:
return agent.stream(message, { sessionId: userId }).toEventResponse()import { readEventStream } from 'just-another-sdk'
const response = await fetch('/api/chat', { method: 'POST', body })
for await (const event of readEventStream(response)) {
if (event.type === 'text.delta') ui.append(event.delta)
if (event.type === 'tool.start') ui.showSpinner(event.toolName)
if (event.type === 'run.finish') ui.done(event.usage)
}Two things happen on the way out, because these bytes leave your server:
- Payloads are redacted, the same treatment
consoleTracerapplies before printing. A tool that handles credentials does not leak them to a client. model.requestis withheld by default — it carries your full tool JSON Schemas. Passincludeto choose exactly what to send:
stream.toEventResponse({ include: ['text.delta', 'tool.start', 'run.finish'] })Each event carries an id: — its index in the run. That is not decoration; it is
what resuming is built on.
Resuming a stream
A client that loses its connection mid-generation normally loses the answer. It
also loses more than that: an ordinary run wired to request.signal is
cancelled on disconnect, and since only a completed run is
persisted, the exchange the user
already watched arrive is never saved.
agent.resumable() records the run as it happens, so it survives the reader:
const run = agent.resumable(message, { sessionId: userId })
return run.toEventResponse() // includes x-stream-idconst from = Number(req.headers.get('last-event-id') ?? -1) + 1
return agent.resume(streamId, { fromIndex: from }).toEventResponse()In a browser this is free: EventSource sends Last-Event-ID automatically on
reconnect, and the id: field from the previous section is what it echoes.
Note what is not passed to resumable(): request.signal. Cancelling on
disconnect is the behaviour it exists to avoid. To stop one early, use an
AbortSignal you control.
Any number of readers can follow the same run, join late, or read it after it has finished — they all read the recording, not the run.
Where recordings live
| Store | Use |
|---|---|
memoryStreamStore() | Default. One process only |
redisStreamStore(client) | Multi-instance. From just-another-sdk/streams |
import { redisStreamStore } from 'just-another-sdk/streams'
const agent = new Agent({
name: 'chat',
model,
streams: redisStreamStore(redis),
})The in-memory store is silently wrong behind a load balancer: a reconnect landing on another instance finds nothing and sees an empty stream. Use Redis the moment there is more than one process.
Recordings are transient — redisStreamStore expires them after an hour by
default (ttlSeconds), and the in-memory one bounds itself by stream count.
Following gives up rather than hanging
An unknown stream id — expired, mistyped, or on another instance — is indistinguishable from a run that has not emitted yet, so a follower cannot wait forever:
| Bound | Default | Fires when |
|---|---|---|
startTimeoutMs | 5_000 | The stream has produced nothing at all |
idleTimeoutMs | 60_000 | A started stream stops producing — a dead writer |
Both end the stream cleanly rather than throwing: from a client's point of view a dead stream and a finished one look the same, and an error would misreport a run that may well have succeeded.
It polls, every 200 ms. Redis pub/sub would be lower-latency but would tie
following to one backend and put a subscription in the interface that in-memory
and SQL stores cannot honour. The interval is well under the gap between tokens,
and a pub/sub fast path can go inside redisStreamStore later without the
interface changing.
It is the same loop
stream() is not a parallel implementation of the agent loop. It runs the
identical runtime with a listener attached, which is why streamed and
non-streamed runs cannot drift apart: same turn accounting, same tool execution,
same stopping rules, same RunResult.
The only difference is where the model's text comes from — provider.stream()
instead of provider.generate().
Providers without streaming
stream() is optional on the provider contract.
A provider that does not implement it still works: its answer arrives as a single
text.delta immediately before model.response.
That means a renderer written against text.delta works against every
provider, and adding streaming to a custom provider later changes only how
smoothly it paints — never whether the code runs.
Event order
A streamed run emits the same events in the same order as a normal run, with
text.delta interleaved:
run.start
model.request → text.delta* → model.response
tool.start … tool.end …
model.request → text.delta* → model.response
run.finishTwo consequences worth relying on:
- Tool execution never overlaps streamed text. Not by policy — by construction. The model's turn must reach its end before its tool calls are known, so there is no text left to stream when a tool starts.
- Tool arguments are never partial. Providers stream them in fragments, but
those are reassembled and validated inside the runtime.
tool.startfires once per call, carrying exactly the input a non-streaming run would produce. No event in the union ever carries half a JSON object.
Cancelling
break does not cancel the run. The work is already in flight and already
billable, and because the stream doubles as a promise, cancelling on break
would break the natural "read a bit, then take the result" pattern:
for await (const event of stream) {
if (sawEnough(event)) break
}
const result = await stream // still resolvesTo actually stop the run, use .abort() or pass a signal:
const stream = agent.stream(prompt)
setTimeout(() => stream.abort(), 5_000)
try {
for await (const event of stream) render(event)
} catch (error) {
if (isAgentError(error) && error.code === 'aborted') ui.setStatus('Cancelled')
}Failures
A failing streamed run reports itself twice, so neither consumption style misses it:
- The iterator yields
run.erroras its last event, then throws. await stream(or.completed) rejects with the sameAgentError.
Iterating without ever awaiting does not produce an unhandled rejection — the runtime marks the result promise as handled for exactly this case.
Buffering, not backpressure
Events are buffered without bound rather than applying pressure to the producer. This is deliberate:
- The event bus is synchronous by contract — the loop never awaits a
listener, so instrumentation cannot slow down or reorder a run. Blocking the
producer would break that for every
onEventconsumer, not just streaming ones. - Real backpressure cannot reach the thing that costs money. To slow the model you would have to stop reading its socket; it keeps generating and you keep paying.
- The buffer is already bounded by the run itself: at most
maxTurnsmodel calls, each capped bymaxOutputTokens, and every token is retained forRunResultanyway.
A slow consumer therefore sees every event, in order, however far behind it runs.
One consumer per run
A StreamedRun can be iterated once. A second iteration throws a
ConfigurationError rather than silently yielding nothing. Use .completed for
the result, or collect what you need in a single loop.
Returning a stream from an async function unwraps it. Any object with a
then method is recursively resolved by the promise machinery, so
async function go() {
return agent.stream(prompt) // ⚠️ resolves to RunResult, not StreamedRun
}Wrap it when it has to cross an async boundary intact:
async function go() {
return { stream: agent.stream(prompt) } // ✅
}Retries mid-stream
If an attempt fails after it has already streamed tokens, the retry starts its
text over. The model.retry event carries discardedText — the
exact characters already delivered and now void:
for await (const event of stream) {
if (event.type === 'text.delta') ui.append(event.delta)
if (event.type === 'model.retry') ui.removeLast(event.discardedText.length)
}In the common case it is empty: a rate limit or a refused connection fails before any bytes arrive. See error handling for the retry policy itself.
API
| Member | Description |
|---|---|
.runId | Available immediately, before the first token. |
[Symbol.asyncIterator]() | Yields every AgentEvent. Once per run. |
await stream | The final RunResult. |
.completed | The same result as a real Promise, for .catch()/.finally(). |
.textStream() | Yields only text.delta strings. |
.toTextStream() | The text as a web ReadableStream<Uint8Array>. |
.toResponse(init?) | That stream as a Response, with streaming-safe headers. |
.toEventStream(options?) | Every event as text/event-stream. |
.toEventResponse(init?) | That stream as a Response. |
.abort(reason?) | Cancels the run: the model call and any running tools. |
All five consumers share one iteration — the one consumer per run rule covers them together.
streamAgent(config, input, options) is the functional form, for when an Agent
instance would not be reused.
Streaming with a schema
An agent with an outputSchema emits no
text.delta events, so .textStream() and .toResponse() produce nothing. The
model's only text is the JSON object, and half an object is not renderable — the
same reason partial tool arguments are withheld.
await stream still resolves to the validated result, and .toEventResponse()
still carries tool progress, output.invalid, and run.finish.
Resumable runs
| Member | Description |
|---|---|
agent.resumable(input, options?) | Starts a recorded run. Returns streamId, runId, completed, toEventStream, toEventResponse |
agent.resume(streamId, options?) | Re-attaches: replays, then follows |
StreamStore | append, read, finish, status |
readEventStream(response, { cursor }) is the client side; the cursor tracks the
last id: seen so a reconnect knows where to start.