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.
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
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 core | P1: best-effort stream | |
|---|---|---|
| Job | Decide and deliver outcomes | Show progress |
| Triggered by | Webhooks, queues, scheduled reconciliation | A watcher reading CMA deltas |
| State | Task metadata in DSQL, transcript in CMA | In memory only |
| On failure | Re-read authoritative state | Drop the partial text |
| Can end a task | Yes | Never |
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
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.
- 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.
- 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.
- 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
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.
| Consumer | Renders frames as | Replaced by |
|---|---|---|
| Web | A growing assistant message | A canonical messages snapshot |
| Slack | A short draft title on an already-open card, throttled to stay well under Slack’s rate limits | The final title and reply from the durable path |
| Another agent | Early context, such as a supervisor or reviewer agent following progress; it can start planning but never acts on preview text | The canonical result from P0, the only input it acts on |
| Next interface | Whatever suits it, via a new subscriber | Its 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
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:
- Start every connection from a canonical messages snapshot.
- Turn preview frames into AG-UI text events as they arrive.
- When canonical content lands, send a fresh snapshot that replaces the provisional text.
- 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 wrong | What the user sees |
|---|---|
| Watcher starts late or hits its deadline | Some live text is missing; the canonical answer fills it in |
| AppSync publish or subscribe fails | No live text; task state and history still update |
| Frames arrive twice or out of order | Duplicates are ignored; a gap freezes that preview |
| Browser reloads | History comes back from canonical state; watching resumes |
| Agent pauses for approval | The 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?




