Multi-replica streaming (Redis)
Swap the in-process token sink for the Redis transport so any pod can serve any run's SSE stream — the same byte-for-byte envelope, fanned across replicas over Redis pub/sub plus a replayable list.
The default token sink buffers a run's tokens in process memory — great for a single instance, but it means a run started on one pod can only be re-attached on that same pod. The Redis transport removes that constraint: any replica can serve any run's SSE stream, with the SSE envelope kept byte-identical.
Select it
import { defineConfig, tokenSinks } from '@adonis-agora/agent'
export default defineConfig({
model: () => aiSdkModel(openai('gpt-4o-mini')),
sink: tokenSinks.redis({ connection: 'main' }),
})tokenSinks.redis (aliased streamTransports.redis) fans a run's tokens across replicas over Redis pub/sub plus a replayable list, so a late or cross-instance subscriber replays the buffered chunks first and then follows live. Requires @adonisjs/redis installed and configured (config/redis.ts).
The default stays single-replica
Omit sink entirely to keep the in-process sink (tokenSinks.memory() selects it explicitly). Nothing about the SSE contract, the routes, or the client changes when you switch — only where the buffer lives. The @adonisjs/redis peer is imported only inside the redis thunk, so it stays fully optional.
Configuration
| Field | Default | Meaning |
|---|---|---|
connection | default connection | @adonisjs/redis connection name. |
keyPrefix | agent:stream | Key/channel namespace. |
ttlSeconds | 3600 | Sliding TTL on a run's replay keys, refreshed on every write. Set 0 to keep them until you clean up yourself. |
client | — | Bring-your-own RedisStreamClient adapter (over ioredis, node-redis, …). When set, @adonisjs/redis is not imported. |
ttlSeconds is the knob that keeps Redis from filling up: the framework never calls the sink's close(), so a run's replay buffer has no natural end of life. One hour is long enough that a client re-attaching after a browser restart still replays the whole run, and short enough that yesterday's transcripts aren't sitting in memory. Raise it if you re-attach on a longer horizon; set 0 only if something else prunes the keys.
What it does
- Write. Each frame is an
RPUSHonto<keyPrefix>:<runId>:chunks, a refresh of that key's TTL, and aPUBLISHon<keyPrefix>:<runId>to wake the subscribers. The element is the frame's JSON with one extra property,"#", a write id no other element shares (the writing process's random id and a counter); subscribers remove it, so they yield exactly the frame written. The end of the run sets<keyPrefix>:<runId>:stateand publishesend. - Coalesce. Consecutive deltas written within 50 ms are pushed as one frame holding their concatenation:
textframes,reasoningdeltas, and thetool-input-deltas of one tool call. Every reader appends these deltas, so the rendered text is the same; a client receives fewer, larger frames. Any other frame, and the end of the run, push the gathered delta first, so order is kept. A process that dies inside that window loses the gathered delta from the live stream; the persisted message is unaffected. - Read. A subscriber replays the list, then follows the channel. It reads the end marker once when it attaches; after that it relies on the
endmessage. After 60 s without a message it reads the list on its own, because expiring keys publish nothing: when the run's keys are gone it ends, and when the list started over (a run parked pastttlSecondsthat then wrote again) it replays from the new first element instead of skipping frames. It tells a restarted list apart by the write id of the last element it read, so a restart that repeats the run's first frames (astep-start, say) is still caught. Elements without a write id, as earlier versions wrote them, are read the same way and compared as they are. - Disconnect. When the browser closes the SSE connection, the route ends its subscription and the subscriber unsubscribes.
Event ids and the `after` cursor
SSE event ids are counted from the list, so every replica numbers a run's events the same and ?after=<seq> resumes exactly on any pod. They are not the ids the in-process sink would have given the same run (it stores every delta as its own frame), which only matters to a cursor carried across a change of sink.
Bring your own client
Passing a client lets you drive a non-Adonis Redis driver or a test double directly — the factory uses it verbatim. Under the hood the adapter uses a dedicated duplicated connection per subscribe (a subscribed Redis connection can't run other commands).
RedisStreamClient.append(key, value, ttlSeconds, channel, message) is optional. It does a frame's RPUSH, EXPIRE (skipped when ttlSeconds is 0) and PUBLISH, in that order, in one round trip — a MULTI or a pipeline. The @adonisjs/redis adapter implements it with MULTI. A client without it gets the three commands one after another, a round trip each.
Pair with the durable runner
The Redis sink solves stream portability across replicas; the durable runner solves run portability and crash-survival (a suspended HITL approval resumes on any pod). Together they give you a horizontally-scaled, crash-proof agent: sink: tokenSinks.redis(...) + durable: true.
No Redis?
tokenSinks.lucid() gives the same cross-replica stream over the app's SQL database, polled instead of pushed. It is slower to deliver and it writes to your database on every streamed answer, so prefer Redis when you have it.
For the underlying class, RedisTokenStreamSink (and the RedisStreamClient interface) are exported from the package root.
Poppy connections (your agent at other companies)
Let your agent act on OTHER Personal Agent Protocol ("Poppy") companies for its users — agent identity, per-user connections sealed at rest, connect links that resume the conversation, company tools and agent behind your confirmation.
Multi-replica streaming (SQL, no Redis)
Keep a run's stream in the app's own database with the Lucid token sink, so any replica can serve or resume any run's SSE without Redis — plus the recipe for parked questions and approvals across replicas.