Three services, one database, one Redis, and a queue between every hop. This page walks every flow step by step with real payloads, and lets you play with the parts that are easiest to get wrong: how typing becomes turns, how the prompt is built, and how sessions end.
Click any box for what it does and where its code lives. Highlighted paths in the flows below are drawn on this map.
Pick a flow, then step through it (or press play). Each step lights up the hops on the map above and shows what actually crosses the wire.
People send a thought in several quick messages. Answering each one separately produces replies that talk over each other, so the client SDK batches them into one turn. Drag the settings and add messages to see where the batch closes. These are the agent's Client & pacing settings.
chat-expert assembles the system prompt in a fixed order: everything that is the same for every conversation of an agent first, then per-conversation material, then per-request instructions. Providers cache repeated prompt prefixes, so this order makes the static part (often most of the tokens, once knowledge is attached) cheap after the first turn. Toggle the layers to see how the cacheable share moves.
Each agent sets a session length. The timer starts when the conversation opens and never pauses. Reopening a live conversation continues it; reopening an ended one shows it read-only. Choose a configuration to see the lifecycle.
| Problem | Mechanism |
|---|---|
| A resent turn creates duplicates | The client resends the identical payload until acknowledged; the queue deduplicates on conversationId:clientTurnId:attempt for 5 minutes, and uq(conversation_id, client_message_id) makes the insert a no-op. |
| A redelivered reply is shown twice | Reply bubbles are stored under a deterministic turn id (r-<turnId>); uq(conversation_id, turn_id, part_index) rejects the second insert. Reveal is a compare-and-set PENDING → SENT. |
| Two replies generated at once ignore each other | At most one generation per conversation (inflight_dispatch_id claim). Messages arriving meanwhile are answered together afterwards, with the first reply in history. |
| Turns of one conversation processed out of order | Ordered queues: each message takes a per-conversation sequence number; a worker runs N only after N−1 (Redis driver), or SQS FIFO groups processed strictly sequentially (SQS driver). |
| A goodbye lands above the answer it should follow | The end waits for an in-flight generation, and a part is never revealed while an earlier part is still pending. |
| A late reply appears under the goodbye | Responses whose dispatch no longer holds the claim are dropped as stale. Ending the conversation clears the claim. |
| A lost generation leaves the user waiting forever | The sweeper releases claims older than GENERATION_TIMEOUT_MS, marks the turn failed and emits turn.failed. The user retries with retryAttempt + 1. |
| A lost reveal job blocks the transcript | Overdue pending parts are rescheduled, and after 10 minutes marked FAILED so later parts can go. |
| Frames sent while the user was offline are missed | Pub/sub is at-most-once on purpose. MySQL is the source of truth, and the client resyncs with ?after=<newest id> on every connect. Offline replies can also go out as push (FCM or a webhook). |
| Client reaches another user's conversation | The session token names one conversation. Every client route and socket channel takes the conversation from the token, never from input. |
| A deploy drops in-flight work | SIGTERM drains queue consumers and sockets (server.shutdown tells clients to reconnect elsewhere). Anything unacknowledged is redelivered. |