Resumable agent streams#
A streamed turn normally dies with its socket: reload the page mid-answer and the answer is gone, along with the tokens already paid for. Turning on retention makes the turn outlive the connection that started it, so a reader that comes back is owed only the part it missed. Guide: The agent and your copilot.
import { defineAgent } from '@janux/agent';
import { serverLlm } from '@janux/agent/local';Turning it on#
Retention is opt-in on the agent, and the browser has to ask for it too — the mount holding a turn is useless if the reader never comes back for it.
// src/agent.ts
export default defineAgent({
harness: { resumableStreams: true },
});// in the page
const llm = serverLlm({ stream: true, resume: true });resumableStreams: true takes the defaults; an object tunes them:
ResumableStreamsConfig |
Default | What it does |
|---|---|---|
ttlMs |
60_000 |
How long a turn stays replayable after its last frame |
maxBytes |
262_144 |
Per-turn retention cap; past it the payload is dropped |
now |
Date.now |
Injectable clock, for deterministic tests |
It is off by default on purpose. A retained turn runs to completion even when nobody is reading it — that is exactly what makes resuming possible, and it is a bill the app has to choose to pay.
The three ways a reader loses a stream#
| What happened | What the reader does | What it gets |
|---|---|---|
| The network dropped | Reconnects with the last id: it received |
Only the frames after it |
| The page reloaded | resumeInterrupted() on the next load |
The turn replayed from the start |
| A second tab opened | resumeInterrupted(), same origin |
The same turn, from the start, live |
The distinction is not fussiness: a dropped socket leaves the text already painted on the screen, so re-sending it would duplicate it. A reload leaves nothing, so replaying the whole turn is continuing it.
// Nothing in flight resolves `undefined`, which is the answer on most page loads.
const carried = await llm.resumeInterrupted();The in-flight stream id lives in localStorage (that is what a second tab can see), and it is removed as soon as the turn ends. Everything else — the cursor — stays in memory, because it only means something to a page that still has the text.
ResumeOptions |
Default | What it does |
|---|---|---|
storage |
localStorage |
Where the in-flight stream id is shared |
key |
'janux:llm-stream' |
The entry to keep it under |
retryMs |
400 |
Pause before a reconnection attempt |
attempts |
3 |
Reconnections before the turn is given up on |
Only progress buys back an attempt: a mount that keeps replaying the same prefix without getting past it is retried a bounded number of times, not forever.
What the wire looks like#
Every streamed frame already carries an incremental id:, and the response carries x-janux-stream-id. Resuming names that id and the last event received:
POST /_janux/llm?stream=<id>
Last-Event-ID: 41POST, even though it reads rather than writes. /_janux/llm is an invocation path, and Janux keeps those closed to cross-origin GETs on purpose — an answer being written for a signed-in visitor is exactly what must not be readable by an <img>, a <script> or an EventSource on somebody else's page. Resuming goes through the same door as every other call rather than carving an exception into that rule.
| Answer | When |
|---|---|
200 + the remaining frames |
The turn is yours and still retained |
404 stream_not_found |
Unknown, expired — or owned by someone else |
422 stream_not_resumable |
It outgrew maxBytes, or retention is off |
A stream belonging to another identity answers exactly like one that never existed. The id is a guess either way, and only one of those two answers is safe to confirm.
Worth being precise about what "another identity" means: without identityFor every caller is anonymous, so ownership rests entirely on the stream id being an unguessable UUID. That is the right trade for a public docs copilot and the wrong one for anything a user signs in to — if answers are private, resolve an identity, and the ownership check becomes a real one.
Resuming is not a way around the limits#
A resume runs the same gate as every other request to the mount: identityFor resolves the caller, and rateLimit counts it. Replaying is cheaper than generating, but a door that skipped the limiter would simply become the door everyone uses.
Retention is bounded in both directions, so an abandoned turn cannot pin memory: it expires after ttlMs, and a turn that outgrows maxBytes has its payload dropped and stops being replayable. Neither ever truncates the live reader — the only thing given up is the ability to replay.
One instance holds the turn#
The log lives in the process that is generating the turn. That is the whole of it — a turn is seconds long and a few KiB, and writing every delta to a database would cost more than the answer.
The consequence is worth stating plainly: behind a load balancer, a resume that lands on a different instance finds nothing. It answers 404, the client stops asking and forgets the stream, and the reader loses the answer exactly as it would have without any of this — no duplicate turn, no error, no wedged page. So this degrades to today's behavior rather than breaking, but on a multi-instance deployment it only helps when the resume happens to come home. Session affinity is what makes it reliable there; a shared log would be the other answer, and createResumableStreams is the seam it would go behind.
createResumableStreams(config)#
The retention log itself, should you want to drive it directly (the mount builds its own from harness.resumableStreams):
import { createResumableStreams } from '@janux/agent';
const streams = createResumableStreams({ ttlMs: 30_000 });| Method | Signature | Notes |
|---|---|---|
open |
(streamId, owner) => void |
Starts retaining a turn; owner is the only identity that may resume it |
append |
(streamId, frame) => void |
StreamFrame is { id, chunk } |
close |
(streamId) => void |
Ends the turn; parked followers are released |
resume |
(streamId, owner, cursor) => AsyncGenerator<StreamFrame> | ResumeFailure |
Frames after cursor, live until the producer closes |
retained |
(streamId) => number |
Bytes currently held; 0 once dropped or forgotten |
Related: The agent and your copilot · Agent rate limiting · Agent memory & storage