Why does the Build track’s workbench start a fresh SDK session on every prompt when a chat app needs one long-lived session? Because the workbench you built through module 20 was built that way, one prompt, one session, done. A chat app cannot live like that. A conversation stays alive, remembers, and reads your next message when it is ready. This module builds that. The one new idea in it is the turn boundary: the rule that the agent reads the next message only when it has finished the current turn, so nothing you type can interrupt work in progress unless you ask it to.
This page opens the chat app’s own repository. The Build track’s workbench keeps its tags; this app is new code in the same method, and its docs/PROTOCOL.md was written before any of it. The frames you know keep their names where the meaning carries: delta, activity, result, error behave as they did in module 16. Two are new, and both say what a chat app is. message replaces prompt, and a queued frame tells you your message arrived mid-turn and is waiting. And session arrives once per conversation, carrying no message id, because the session belongs to the conversation now.
flowchart LR subgraph browser["browser"] a["send message"] end subgraph queue["server's queue"] b["held, queued<br/>frame sent"] end subgraph loop["SDK loop"] c["one query(),<br/>alive all along"] end a --> b b -->|"turn boundary"| c
The prompt becomes a stream
Here is the mechanism. The SDK’s streaming input mode, from the streaming-vs-single-mode page, accepts an async iterable of user messages instead of a string, and the session stays open as long as the iterable does. The chat server’s whole conversation is one query() whose prompt is an async generator reading from a queue. Record 0007 cites the exact union member and message shape off the installed sdk.d.ts.
The generator’s wait is the module, so here is the file it lives in. Open server/src/conversation.js first and read the annotations in order: the wake, then the loop that reads from the queue, then the one method the client’s messages arrive through. docs/PROTOCOL.md sits beside it because the frames were written before the server was, and the boundary is a promise the protocol makes rather than a behaviour the code happens to have.
server/src/conversation.js The queue, the wake, and the turn boundary. Two regions of an 804-line file.
// Lines 1 to 266 elided: imports, the frame types, and the constructor. Full file: server/src/conversation.js at tag module-29.
// -------------------------------------------------------------------------
// The wake mechanism
// -------------------------------------------------------------------------
/**
* Park until something changes.
*
* This is the line a reader is walked through. It returns a promise that
* nobody has resolved yet, and hands the resolve function to `#wake`. Awaiting
* it suspends the generator: the function stops where it is, its local state
* intact, and the JavaScript runtime goes off and does other work, reading
* SDK messages, answering the socket. Nothing polls, nothing spins, and the
* generator costs nothing while it is parked.
*
* It stays parked until somebody calls `#nudge()`. That is the only way out.
*
* @returns {Promise<void>}
*/
#park() {
return new Promise((resolve) => {
this.#wake = resolve;
});
}
/**
* Let the parked generator have another look at the world.
*
* `#wake` is swapped for a no-op before it is called, for two reasons. It
* makes a second `#nudge()` before the generator re-parks harmless, and it
* drops the reference to the old promise's resolver so nothing holds it.
*
* A nudge does not mean "read the next message", it means "the conditions
* changed, check them again". The generator does the checking. That split is
* what keeps the boundary rule in one place: every caller of `#nudge()` can
* be wrong about whether the generator should move, and the rule still holds.
*/
#nudge() {
const wake = this.#wake;
this.#wake = () => {};
wake();
}
// -------------------------------------------------------------------------
// The generator the SDK pulls from
// -------------------------------------------------------------------------
/**
* The prompt, as an async generator of user messages.
*
* This is streaming input mode. The SDK's `query()` accepts either a string,
* one prompt, one session, done, or an `AsyncIterable<SDKUserMessage>`, which
* is a session that keeps asking for more. Verified against
* @anthropic-ai/claude-agent-sdk 0.3.251, sdk.d.ts lines 2839 to 2842:
* `query(_params: { prompt: string | AsyncIterable<SDKUserMessage>; options?:
* Options })`, with the prompt type on line 2840.
*
* The loop below has two waits in it and they enforce different halves of the
* same rule:
*
* `this.#current !== null`, a turn is open. Do not read anything, however
* long the queue is. This is the turn boundary.
*
* `this.#queue.length === 0`, nothing to read. Wait for someone to speak.
*
* The second is ordinary. The first is the point of the module, and it is
* worth being clear about why this module enforces it rather than trusting
* the SDK to. The SDK pulls one value per turn, so in practice it would not
* ask again mid-turn. But "in practice" is not a contract, and the cost of
* being wrong is a `delta` frame filed under the wrong message id, which the
* protocol calls a violation and a client is told to treat as invalid. The
* condition is cheap, and it makes the rule this server's rather than a
* behaviour it is relying on.
*
* @returns {AsyncGenerator<object, void>}
*/
async *#userMessages() {
for (;;) {
while (this.#current !== null || this.#queue.length === 0) {
if (this.#closed) return;
await this.#park();
}
// `shift` takes the oldest. Messages are answered in the order they were
// read, and they are read in the order they arrived.
const next = /** @type {QueuedMessage} */ (this.#queue.shift());
this.#current = next;
this.logger.debug("turn.start", { id: next.id });
yield toUserMessage(next.text);
// Execution resumes here when the SDK asks for the next value, which it
// does when it is ready for another turn. Nothing is done with that fact
// beyond looping: `#current` is cleared by the result handler, not here,
// because the result is what ends a turn and the pull is only what starts
// the next one.
}
}
// The SDK message loop elided. Next: the one method a client's message arrives through.
/**
* Take one message from the client.
*
* The `queued` decision is made here, before the push, and it is deliberately
* not "is a turn open". It is "would this message have to wait", which is
* true if a turn is open OR if something is already queued ahead of it. Two
* messages arriving in the same tick while the agent is idle is the case that
* separates the two rules: the first will be read at once, the second will
* not, and only the second should hear `queued`.
*
* @param {string} id
* @param {string} text
*/
send(id, text) {
if (this.#closed) {
this.emit({ type: "error", id, message: "the conversation is closed" });
return;
}
if (this.#knows(id)) {
this.emit({
type: "error",
id,
message: `id ${JSON.stringify(id)} is already in use on this connection`,
});
return;
}
const willWait = this.#current !== null || this.#queue.length > 0;
this.#queue.push({ id, text });
// Nudge before the frame, so the generator is already moving while the
// frame is written. Neither order is wrong; this one keeps the agent's
// start time independent of how long the socket takes.
this.#nudge();
if (willWait) {
this.logger.info("message.queued", { id, depth: this.#queue.length });
this.emit({ type: "queued", id });
} else {
this.logger.info("message.accepted", { id });
}
}
// Cancel, interrupt and shutdown follow, and end the file at line 804. Read more
The rest of the file is the SDK message loop that turns SDK messages into delta, activity and result frames, the cancel path, and the shutdown path. Everything shown here is verbatim from the module-29 tag of the chat app's repository.
docs/PROTOCOL.md The whole contract, 66 lines, written before the server was.
# Chat protocol, version 0
One WebSocket at `ws://<host>/ws`. Every frame is one JSON object with a `type`
field. Every client message carries an `id` the client chose (any string unique
within the connection); every server frame that answers or reports on it echoes
that `id`.
This file is the contract between `server/` and `client/`. Change it only with
a decision record in `docs/decisions/` and a matching change on both sides.
Version 0 clients ignore server frame types they do not know; a server answers
a client frame type it does not know with an `error` echoing the `id` when one
is present. New frames and new fields are added by that rule, and
`ready.protocol` stays 0 for additive change.
This app is a conversation, not a prompt box, and the frames say so. The
workbench from the Build track runs one SDK session per prompt; this app runs
one long-lived session per conversation, fed by a queue, and a message sent
while the agent is mid-turn waits for the turn boundary. The frames a Build
track reader already knows keep their names and meanings where the semantics
carry over.
## Client to server
| `type` | Fields | Meaning |
| --------- | ------------ | ----------------------------------------------------------------------------------------------------- |
| `message` | `id`, `text` | Say something to the agent. Read at the next turn boundary, never sooner. |
| `cancel` | `id` | Withdraw the message with this id if it is still queued; interrupt the turn if it is being worked on. |
## Server to client
| `type` | Fields | When |
| ---------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ready` | `protocol` (number, `0`), `workspace` (string), `fake` (bool), `settings` (string) | Once, right after the socket opens. `settings` follows the Build track's module 20 meaning: `"isolated"` or `"inherited"`. |
| `queued` | `id` | The message is in the queue and the agent is mid-turn; it will be read at the turn boundary. Not sent when the agent was idle and the message is read at once. |
| `session` | `sessionId`, `model` | Once per conversation, when the underlying SDK session starts. No `id`: the session belongs to the conversation, not to any one message. |
| `delta` | `id`, `text` | A piece of the reply to the message with this id, in order. |
| `activity` | `id`, `tool`, `phase` (`start` or `done`), `summary` | A tool the agent ran while working on this message. Same shape and one-line summary contract as the Build track's module 16. |
| `result` | `id`, `text` | The complete reply to the message with this id. Deltas for an id concatenate to a prefix of it, single-turn caveat as in the Build track. |
| `error` | `id` (optional), `message` | Anything that stopped a message from being answered, or a malformed frame. |
Order for one message: optionally one `queued`, then any number of `delta` and
`activity` frames, then exactly one `result` or `error`. Messages are answered
in the order they were read from the queue, one at a time: the turn boundary is
the rule that no second message is read while a turn is open. A frame for an id
arriving after that id's `result` or `error` is a protocol violation a client
treats as invalid rather than rendering.
A `cancel` for a queued message removes it from the queue and answers it with
an `error` saying it was withdrawn. A `cancel` for the message being worked on
interrupts the turn; the interrupted message is answered with an `error`. A
`cancel` for an unknown or finished id is answered with an `error` naming it.
On the wire those endings are one frame type, and the `message` text is prose:
nothing machine-readable says whether a message was withdrawn, interrupted, or
failed on its own. A client that must tell them apart records its own intent
before sending the `cancel`; a reason field is a later module's addition if a
second observer of the same conversation ever needs one.
The conversation survives the socket where the SDK session does: `session`
carries the `sessionId`, and reconnect behavior is a later module's subject,
named here only so nobody reads "once per conversation" as "once per socket"
and builds on the wrong one.
## Reserved for later modules
Nothing is reserved. Later modules add roster, inter-agent mail, caps, and
approval frames by the additive rule at the top of this file. Two clauses in that condition, and they are not the same kind of thing. The second is ordinary waiting: the queue is empty, nothing to do. The first is the turn boundary, even with a message waiting, the generator does not yield it while a turn is open. Record 0008 argues why the boundary is a condition here rather than trust in the SDK’s own pacing: every delta and activity frame is attributed to #current’s id, and if a second message could be read mid-turn there would be nothing sound to attribute them to. #park() and its #nudge() are the wake: one stored resolver, the function that ends a wait, swapped before it fires for a no-op, a stand-in function that does nothing. The nudge never decides whether to advance. The condition decides; the nudge only wakes the code that checks it.
Streaming input also buys the thing a chat app cannot do without: interrupt(). The SDK supports it only in this mode, which the workbench could never use because its string prompts closed the input stream at birth. Cancel is now two operations on one frame (record 0010): a cancel for a queued message withdraws it, and a cancel for the message being worked on calls interrupt() while the conversation itself stays alive. There is no abort controller anywhere in this server, because killing the conversation is exactly what cancel must not do.
Watch the boundary
git clone https://github.com/01000001-01001110/chat-workbench.git && cd chat-workbench
npm install
WORKBENCH_FAKE_SDK=1 npm run dev
Type a message, and while the reply is still streaming, type another. The second message appears in the thread wearing its badge, “Queued, read at the turn boundary”, the composer never locks (that is the client’s one organizing rule, record 0022), and the badge clears the moment the second message’s own reply begins. The demo script shows the same thing as frames, from a real run:
7ms session sessionId=fake-session-0000 model=fake-model
128ms activity [one] start Read notes.md
312ms --> message two (mid-turn)
312ms queued [two]
624ms delta [one] "Fake mode is on, so no model wa"
999ms result [one]
1123ms delta [two] <- nothing for two before one's result
1496ms result [two]
1496ms --> message three (idle)
1620ms delta [three] <- no queued frame: the agent was free
One session frame for the whole exchange, queued only for the message that had to wait, zero interleaving across the boundary. The fake’s contract (record 0017) is that it accepts many messages on one session so this transcript costs nothing; both test suites were validated by mutation, meaning the code under test was deleted to prove the tests notice, and deleting the boundary clause from the wait fails two of them.
The ladder:
- Run it. Two messages in a row, fast. One
sessionIdin the header the whole time; the second message queues, then answers. - Read one file.
server/src/conversation.js, and find the line that waits. Say out loud which clause is the boundary and which is the empty queue. - Change one line and see it. Start the server with
WORKBENCH_FAKE_DELAY_MS=600and the queue becomes easy to watch by hand; set it to0and the queued badge becomes nearly impossible to produce, which tells you the badge was always about timing, not about you. - Build. The queued badge says what it is waiting for but not how many are ahead of it. Add a position to the
queuedframe’s rendering using only what the client already knows from its own state, and prove it with the unit suite still green:npm test -w client.
You write the queue first and it works: push a message, the generator picks it up. Then you refactor, and the push happens without resolving the wake promise. The reader types a second message. Nothing appears. No error, no frame, /healthz answers fine, the process is alive and perfectly healthy, and the message sits in a queue nobody will ever check, because the generator is parked on a promise nobody resolved. The next message wakes it, by accident, and both answer at once, which is how this bug usually gets misdiagnosed as “sometimes slow”.
The fix is three lines, and the lesson is the shape: a queue with no wake is a mailbox with no flag. That is why the wake in this server is one stored resolver swapped before it fires, why the condition and the nudge are separate jobs, and why the conversation tests park a message mid-turn and assert the exact moment it is read rather than asserting “it eventually answered”.
The queue-and-turn-boundary shape is general: any agent runtime that reads input between turns needs exactly this, and the mailbox-with-no-flag bug exists in all of them. What does not travel is the surface: prompt-as-async-iterable, the user message shape, and interrupt() being conditional on streaming input are this SDK’s own, and the streaming-input page against your pin is the reference.
Check yourself
- A message arrives while the agent is mid-turn. Name every frame the sender sees, in order, and the one rule that fixes where the
queuedframe stops being possible. - Your teammate replaces the generator’s
whilecondition withthis.#queue.length === 0only, and all the streaming tests still pass on a quiet afternoon. What did they break, and what would a user have to do to see it? - Why does this server have no
AbortController, when the workbench’s cancel path was built on one? What would aborting cost here that it did not cost there?