← All articles

Rustem Feyzkhanov · Writing

Durable Agents, Disposable Streams

Live text makes a long agent run feel shorter. It should never be the thing that decides whether the run is finished. Here is how I split the two in a Claude Managed Agents app: an event-driven core that owns every outcome, and a streaming layer that is allowed to fail.

One CMA execution feeds two routes to Web and Slack: optional live previews travel through an observer and AppSync Events; canonical answers arrive through durable reconciliation, replace the previews and determine completion.

The question a stream can’t answer

Picture a user who starts a request in Slack and then opens the same conversation in a browser. The agent needs a minute or so. Live text on both screens makes that minute bearable, so streaming feels like an obvious feature to add.

Then the edge cases arrive. The browser reloads in the middle of a sentence. The stream attaches after the model has already started writing. Slack throttles an update. The text on screen stops moving, and nobody can say what that means. Is the agent done, stuck, or waiting for approval? Is the half paragraph the answer?

A stream can tell you what is happening right now. It cannot tell you what happened. Durable execution answers that second question, and the two get tangled the moment you let one depend on the other. This post is about keeping them apart.

Two priorities, two paths

P0 reconciles durable work into canonical task state and final answers. P1 observes live deltas and distributes disposable previews; losing that path does not prevent completion.

I split the system by priority, not by component.

P0 is the durable workflow, and it is event-driven. It accepts input, runs the task, handles tool approvals, delivers the final answer and recovers from restarts and lost notifications. Signed CMA webhooks wake a queued scheduler, which re-reads the canonical session history from CMA. A reconciliation pass on a short schedule catches any webhook that never showed up. Task metadata lives in Aurora DSQL; the transcript itself stays in CMA.

P1 is best-effort streaming. It carries provisional assistant text to whoever is watching. It keeps nothing, promises nothing, and can disappear at any time.

P0: event-driven coreP1: best-effort stream
JobDecide and deliver outcomesShow progress
Triggered byWebhooks, queues, scheduled reconciliationA watcher reading CMA deltas
StateTask metadata in DSQL, transcript in CMAIn memory only
On failureRe-read authoritative stateDrop the partial text
Can end a taskYesNever

The one rule that makes this work: nothing on P1 is allowed to make a decision. A preview’s end marker closes the preview, not the task. If I deleted every P1 component tomorrow, users would see less while they wait, but no work, approval or answer would be lost.

Event-driven design alone does not make P0 reliable. What makes it reliable is persisted task state, stable identities for every input, fenced worker claims and reads that recover from the source of truth. Those are exactly the guarantees I refuse to give the stream, because giving them would turn streaming into part of execution.

If you read my previous post, Distributed Agents, Centralized Authorization, this is the same controller and the same recovery reads that let a tool approval wait in Slack for as long as a person needs. This post is about the part of the system that deliberately gets none of that.

Why CMA makes streaming harder than it looks

CMA invocation timeline: the controller sends user input and CMA starts a model request before a separate preview observer attaches. The late observer receives no deltas for that request. P0 still reconciles canonical history and delivers the final answer to Web or Slack. Time flows downward; spacing is illustrative.

When you call a model API yourself, the process that receives tokens can pass them straight to the client. Streaming is nearly free.

With Claude Managed Agents, the agent runs on Anthropic’s side and my application only waits for it. That changes three things.

  1. Incremental text is opt-in and best effort. Regular agent messages arrive when a model request completes. Token-level deltas are a separate, opt-in display aid, and the buffered message remains the authority.
  2. A stream only sees what happens after it opens. The platform’s guidance is to open the stream before sending the user message. Doing that would make “stream attached” a precondition for accepting work, which puts P1 back on the P0 path. So I accept input first, hand off the P0 wakeup, and only then make a bounded attempt to start the stream. The cost is a race I accept on purpose: a fast answer can finish before the watcher sees a single delta.
  3. You need another worker just to get updates. Someone has to hold a connection open against a run that executes somewhere else. Managed execution means I don’t host the agent anymore, but I still have to host something that watches it. I don’t want that loop inside the controller, so it gets its own worker.

That third point is the real cost of streaming with a managed agent, and it is worth saying plainly: the watcher is extra infrastructure whose only purpose is a better wait.

The watcher: small, bounded, powerless

The watcher is a Lambda function that reads assistant-message deltas for one task, batches them into small frames a few times a second and publishes them. Its design is mostly a list of things it cannot do.

  • It has no way to send user input, approve a tool or write task state. Its interface simply doesn’t include those operations.
  • It ignores start hints that are too old to matter.
  • It re-checks every couple of seconds that the task is still in a state worth watching.
  • It stops when the session goes idle, on any error, or at a hard deadline of a couple of minutes.

Killing it costs nothing but live text. That is the property I want from every P1 component.

One reader, many consumers: AppSync Events as the contract

One CMA observer publishes a shared preview contract to AppSync Events at the center. Independent Web, Slack and future-interface adapters subscribe to the same task channel and choose their own presentation without adding another CMA reader.

Web and Slack both want live text, and a third interface is always around the corner. If every viewer opened its own connection to CMA, each one would add a reader for the same run and drag provider-specific stream handling into every client.

Instead, the watcher publishes to a single AppSync Events channel per task, and each consumer subscribes independently. AppSync handles the parts I don’t want to own: WebSocket connections, broadcast, HTTP publishing and per-namespace authorization. The watcher publishes with IAM; consumers only get connect and subscribe. The publisher never knows, or needs to know, whether a frame lands in a browser, a Slack card, or nowhere.

The channel is the transport. The contract is the payload, and I keep it tiny and versioned:

type Preview = {
  v: 1;
  taskId: string;
  previewId: string;
  offset: number; // frame sequence number, not a token position
  type: "start" | "delta" | "end";
  text?: string;
};

There are no provider session IDs, tool arguments or task states in it. Consumers drop duplicates, and when they see a gap they stop extending that preview instead of stitching later text onto earlier text. A preview that looks unfinished is better than one that looks finished and isn’t.

This is what I mean by a universal contract: every interface speaks the same small preview vocabulary, and each one decides how to show it.

ConsumerRenders frames asReplaced by
WebA growing assistant messageA canonical messages snapshot
SlackA short draft title on an already-open card, throttled to stay well under Slack’s rate limitsThe final title and reply from the durable path
Another agentEarly context, such as a supervisor or reviewer agent following progress; it can start planning but never acts on preview textThe canonical result from P0, the only input it acts on
Next interfaceWhatever suits it, via a new subscriberIts own canonical view, with no new CMA reader

In Slack, speculative text never becomes a message. Only the title of a card that already exists changes, and the durable path overwrites it when the task settles.

The browser edge: a Lambda Web Function as the AG-UI bridge

A Lambda Web Function runs an HTTP server that checks ownership in Aurora DSQL, calls the private A2A controller, subscribes to optional AppSync previews and streams AG-UI to the browser. Canonical snapshots replace previews. Event Functions invoke handlers per event; Web Functions serve concurrent HTTP requests directly. Both can stream responses.

The browser needs an application endpoint whether or not anything streams. Something has to authenticate the user, check that they own the conversation, accept input, restore history and translate task state into UI events. That same endpoint is the natural place for canonical output and live previews to meet.

Lambda Web Functions let that endpoint be a plain HTTP server. Mine is Hono on Node behind CloudFront. One execution environment serves concurrent requests and streams responses directly, which fits an endpoint that spends most of its time waiting and then writing server-sent events.

For each connection, the gateway checks ownership, reads canonical history from the controller, optionally subscribes to the task’s preview channel, and speaks AG-UI to the browser. AppSync credentials and CMA session IDs stay on the server.

The merge logic is where the P0/P1 split becomes code:

  1. Start every connection from a canonical messages snapshot.
  2. Turn preview frames into AG-UI text events as they arrive.
  3. When canonical content lands, send a fresh snapshot that replaces the provisional text.
  4. Let task state, never a preview frame, decide whether the run is finished or needs a human.

The gateway also owns connection hygiene: a regular heartbeat, bounded writes, and a planned reconnect before long-lived connections age out. When a browser disconnects, the subscription is released and the task carries on. Disconnecting is not cancelling; Stop is its own authenticated call.

This is why I like a Web Function here. Provider-specific streaming stays behind a standard protocol, the browser sees one coherent stream, and deadlines and cleanup live in exactly one place.

Designing for the stream to vanish

Back to the screen where the text stopped moving. With this split, a stalled preview always means the same thing: nothing. Status comes from P0.

What goes wrongWhat the user sees
Watcher starts late or hits its deadlineSome live text is missing; the canonical answer fills it in
AppSync publish or subscribe failsNo live text; task state and history still update
Frames arrive twice or out of orderDuplicates are ignored; a gap freezes that preview
Browser reloadsHistory comes back from canonical state; watching resumes
Agent pauses for approvalThe watcher stops; the approval UI comes from P0

The test that matters most is the one where the stream produces nothing at all. In my live rollout against real CMA, one run streamed to two signed-in viewers at once, and two other turns showed no live text. All of them ended with the correct answer. Those silent turns are the better evidence, because they show P0 does not quietly lean on P1.

What it costs, and what it leaves out

Best effort is not free. The watcher holds a Lambda invocation for as long as it watches. AppSync adds connection and message charges. The Web Function keeps environments alive while it waits, and waiting on I/O still costs memory time. Batching and deadlines keep this bounded, but it has to be measured, not assumed.

I also chose not to solve some things:

  • No replay. Joining mid-answer gives you canonical history plus new frames, not the deltas you missed.
  • The first tokens are the easiest to miss, because the stream starts after input is accepted.
  • Long answers can outlive the watcher. The answer still arrives; only the live text stops.
  • Text only. Tool calls, reasoning and approval details never travel on the stream.

In return, I have one place to make every streaming trade-off. I can turn previews off, shorten the watch window or change how an interface renders progress without touching the task lifecycle.

The takeaway

Treat streaming as a P1 feature on top of an event-driven P0 core. Let P0 own every decision. Give P1 a single, powerless watcher, one fanout channel with a small provider-neutral contract, and one bridge to the browser. Then make sure the canonical answer always replaces whatever the preview showed.

Before you add streaming to an agent, write down what happens when the stream dies halfway. If the answer is “the task gets stuck”, streaming has become part of execution. If the answer is “the user waits a little longer”, you’ve got it right.

How are you streaming from long-running or managed agents today: straight from the provider, through your backend, or not at all?