just-another-sdk

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:

app/api/chat/route.ts
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:

server
return agent.stream(message, { sessionId: userId }).toEventResponse()
browser
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 consoleTracer applies before printing. A tool that handles credentials does not leak them to a client.
  • model.request is withheld by default — it carries your full tool JSON Schemas. Pass include to 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:

start
const run = agent.resumable(message, { sessionId: userId })
return run.toEventResponse() // includes x-stream-id
reconnect — a different request, possibly a different instance
const 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

StoreUse
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:

BoundDefaultFires when
startTimeoutMs5_000The stream has produced nothing at all
idleTimeoutMs60_000A 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.finish

Two 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.start fires 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 resolves

To 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.error as its last event, then throws.
  • await stream (or .completed) rejects with the same AgentError.

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 onEvent consumer, 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 maxTurns model calls, each capped by maxOutputTokens, and every token is retained for RunResult anyway.

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

MemberDescription
.runIdAvailable immediately, before the first token.
[Symbol.asyncIterator]()Yields every AgentEvent. Once per run.
await streamThe final RunResult.
.completedThe 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

MemberDescription
agent.resumable(input, options?)Starts a recorded run. Returns streamId, runId, completed, toEventStream, toEventResponse
agent.resume(streamId, options?)Re-attaches: replays, then follows
StreamStoreappend, read, finish, status

readEventStream(response, { cursor }) is the client side; the cursor tracks the last id: seen so a reconnect knows where to start.

On this page