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.
The default token sink buffers a run's frames in process memory, so a run can only be watched — and re-attached — on the replica that started it. The Redis transport removes that limit. The Lucid sink removes it too, for a deployment that has a SQL database and no Redis: frames are rows in the app's database, and every replica reads them.
Select it
import { defineConfig, stores, tokenSinks } from '@adonis-agora/agent'
export default defineConfig({
model: () => aiSdkModel(openai('gpt-4o-mini')),
store: 'lucid',
stores: { lucid: stores.lucid() },
sink: tokenSinks.lucid(),
})Nothing about the routes, the SSE envelope or the client changes. The sink works with any store (it only needs a Lucid connection). It is tested on SQLite, Postgres 16 and MySQL 8.4.
What it does
- Write. The replica running the turn appends each frame to
agent_stream_frame(run_id,seq,frame,created_at; primary key(run_id, seq)).seqstarts at 1 per run and has no gaps. The row whoseframeisNULLis the end marker. - Read. A subscriber reads the rows past its cursor, in order, every
pollIntervalMs, until it meets the end marker. One that attaches late — or after the run ended — replays from the first row. Each read also re-reads the last row the subscriber already has, which is how it notices that the rows it read are gone: when the run has no rows left (purged past its TTL, orclose()d) the subscriber ends, and when the run's numbering started over (a run parked past its TTL that then wrote again) it replays from the new first row instead of skipping frames. - Coalesce. A model streams token by token, and a row per token is more writes than a chat answer deserves. Consecutive deltas written within
flushMsare stored as one frame holding their concatenation:textframes,reasoningdeltas, and thetool-input-deltas of one tool call. Every reader appends these deltas, so the merged frame renders the same. Any other frame, and the end of the run, write the gathered delta first, so order is kept. - Disconnect. When the browser closes the SSE connection, the route ends its subscription, so a run parked on a person is not polled for a client that left.
Event ids and the `after` cursor
SSE event ids are counted from the stored frames, and every replica reads the same rows in the same order — so GET <path>/chat/:runId/stream?after=<seq> resumes exactly, whichever replica answers. The ids are not the ones 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 — a deploy that switches sinks should let running streams finish.
Configuration
| Field | Default | Meaning |
|---|---|---|
connection | default connection | Lucid connection name. |
tableName | agent_stream_frame | The frame table. |
pollIntervalMs | 250 | Gap between two reads of a run being followed — the most a frame waits before a browser sees it. |
idlePollIntervalMs | 1000 | Gap once the run has written nothing for five seconds (parked on a human, a slow tool). The next frame puts the subscriber back on pollIntervalMs. |
flushMs | 50 | Window consecutive deltas (text, reasoning, one tool call's arguments) are gathered over, counted from the first one. 0 writes every frame as its own row. |
ttlSeconds | 3600 | How long a run's rows are kept after its last write. 0 keeps them until you delete them. |
autoPurge | true | Purge lapsed runs after a run ends, at most once a minute per replica. |
autoCreateTables | true | Create the frame table as the app starts. With false, the published migration (createAgentTables) creates it. |
db | — | Bring-your-own Lucid database. When set, 'lucid.db' is not resolved and connection is ignored. |
Cleanup
A run's rows lapse ttlSeconds after its last write — an ended run past its replay window and a run that crashed without ending alike. autoPurge deletes them as a side effect of streaming, which is enough for a replica that keeps serving turns. To purge on a schedule instead (or as well), call the sink yourself:
import { LucidTokenStreamSink } from '@adonis-agora/agent'
import db from '@adonisjs/lucid/services/db'
// from a scheduled job, on any replica
await new LucidTokenStreamSink(db).purgeExpired()close(runId) deletes one run's rows at once.
The purge reads the (created_at, run_id) index on the frame table to find runs with a row older than the cutoff, then checks those candidates for a newer row through the primary key. Its cost follows the number of rows that aged out, not the size of the table.
Upgrading with autoCreateTables: false
agent_stream_frame and its agent_stream_frame_created_idx index (created_at, run_id) are created by createAgentTables. With autoCreateTables: true the sink adds a missing index as the app starts. If your schema is owned by migrations, add one whose up() calls createAgentTables again — it only adds what is missing. Under a custom tableName, the index is <tableName>_created_idx, and streamFrameIndexStatement(tableName) returns its DDL.
What it costs
- Latency. Delivery is polled: a frame reaches the browser up to
pollIntervalMs(plusflushMsfor text) after it was produced. Text arrives in small bursts rather than token by token. - Database load. A streamed answer is roughly twenty inserts a second while the model is talking, and each open SSE connection is four small indexed reads a second (one, once the run goes quiet). That is fine for tens of concurrent chats on a database that is already there; it is the wrong place for hundreds.
- A crash window. Text a writer is still gathering lives in the process for at most
flushMs. A process that dies in that window loses it from the live stream; the persisted message is unaffected. - A parked run outlives its buffer. The TTL counts from the run's last write, so a run parked on a person for longer than
ttlSecondshas its buffered frames purged while it waits. It still resumes and writes what comes next, but a browser attaching afterwards replays only from there — the persisted thread is the record of what came before. RaisettlSeconds(or set0and purge on your own schedule) if people routinely answer later than that.
Use Redis when you already run it, when you want pushed rather than polled delivery, or when the number of concurrent streams makes polling your database the bottleneck. There is no LISTEN/NOTIFY wake-up on Postgres: the sink stays on the portable query surface every Lucid dialect has.
Several replicas without Redis
The sink makes the stream portable. A run parked on a person — an action approval, or a question set from ask / intake — also has to be resumable from whichever replica receives the reply. Under the inline runner that wait is a promise in one process; under the durable runner it is a journaled signal. So the whole recipe is three lines of config:
import { defineConfig, stores, tokenSinks } from '@adonis-agora/agent'
export default defineConfig({
model: () => aiSdkModel(openai('gpt-4o-mini')),
store: 'lucid',
stores: { lucid: stores.lucid() },
durable: true, // parking is a durable signal, not a promise in one process
sink: tokenSinks.lucid(), // any replica serves and resumes any run's stream
defaultAgent: { ask: true }, // the model may put a question set to the user
})with @adonis-agora/durable configured on a store and a transport that are themselves shared between replicas without Redis — its Lucid store and its db transport (config/durable.ts).
What then happens to a question:
- Replica A runs the turn. The model calls
ask; the loop writes theelicitationframe and the workflow suspends on the signaltool:<runId>:<callId>. Nothing is held in memory. - The browser's SSE connection — on A, on B, or re-opened later with
?after=— reads the frame from the table. POST <path>/tool-call/answerlands on replica B, which signals the engine. Whichever replica's worker claims the run resumes it, and its frames go to the same table.
The same holds for POST <path>/tool-call/approve and /reject.
For the underlying class, LucidTokenStreamSink is exported from the package root.
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.
Diagnostics & tracing
The ten lifecycle events and four span channels the agent publishes — their exact names and payloads, what is deliberately redacted, and how to subscribe.