Module 29 · 55 min

One Agent That Stays Alive

You can keep one agent's session alive across many messages, write the queue that feeds it, and say exactly when the next message will be read.

Surface
streaming input · interrupt()
Workbench tag
module-29
Claude Code
v2.1.251 (bundled)
claude-agent-sdk
v0.3.251
Docs checked
2026-08-30

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
Three lanes. A message arriving mid-turn waits in the middle one; the arrow out of the queue is the rule this module exists to teach.

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.

chat-workbench @ module-29/
├─ server/
│ └─ src/
└─ docs/
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.

Two files at the module-29 tag. Read conversation.js first, annotation by annotation; PROTOCOL.md is the contract it implements.

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.

Build

The ladder:

  1. Run it. Two messages in a row, fast. One sessionId in the header the whole time; the second message queues, then answers.
  2. 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.
  3. Change one line and see it. Start the server with WORKBENCH_FAKE_DELAY_MS=600 and the queue becomes easy to watch by hand; set it to 0 and the queued badge becomes nearly impossible to produce, which tells you the badge was always about timing, not about you.
  4. Build. The queued badge says what it is waiting for but not how many are ahead of it. Add a position to the queued frame’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.
The mistake most people make first

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”.

Does this travel?

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

  1. 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 queued frame stops being possible.
  2. Your teammate replaces the generator’s while condition with this.#queue.length === 0 only, 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?
  3. 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?