Concurrent runs
Your application runs several turns on one conversation at the same time. Each run carries its own id, so each has its own stream, its own cancel signal, and its own lifecycle.
Concurrent runs let several turns stream at the same time on one conversation. Each has its own stream, its own cancel signal, and its own lifecycle. You can build interruption, several people in one conversation, and multi-agent setups on top of them.
How it works
Runs are multiplexed on one channel by run id. Every message a run publishes, whether text deltas, tool calls, or lifecycle events, carries the run's id in its transport headers, and the decoder surfaces it as meta.runId. That header is the whole separation mechanism: there is no second channel and no per-run subscription.
On the client, publish twice and key what you render on the run id each message carries:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Client-side.
const summary = await transport.publishInput({ kind: 'message', payload: summaryPrompt });
const risks = await transport.publishInput({ kind: 'message', payload: risksPrompt });
await Promise.all([wakeAgent(summary.eventId), wakeAgent(risks.eventId)]);
const panelFor = new Map([
[await summary.runId, 'summary'],
[await risks.runId, 'risks'],
]);
transport.subscribe((event) => {
if (event.kind === 'message') renderTo(panelFor.get(event.meta.runId), event.outputs);
if (event.kind === 'run-lifecycle' && event.event.type === 'end') {
markDone(panelFor.get(event.event.runId), event.event.reason);
}
});On the agent, each run is an independent handle with its own abort signal and lifecycle. Two invocations of your endpoint open two runs on the same channel, and nothing coordinates them:
1
2
3
4
5
6
7
8
9
10
11
12
// Agent-side. One invocation, one run.
const trigger = await transport.locateInput(eventId);
const run = transport.openRun({ inputCodecMessageId: trigger?.meta.codecMessageId });
const result = streamText({
model: anthropic('claude-sonnet-4-20250514'),
messages: conversation,
abortSignal: run.abortSignal,
});
const { reason } = await run.pipe(result.toUIMessageStream());
await run.end({ reason });One transport can hold several open runs at once. A cancel routes to the run whose id it carries, so abortSignal fires on that handle alone and the others keep streaming.
Track live runs
The transport does not keep track of live runs for you, so tracking them is your own bookkeeping over the lifecycle stream:
1
2
3
4
5
6
7
8
9
// Client-side.
const live = new Map(); // runId -> clientId
transport.subscribe((event) => {
if (event.kind !== 'run-lifecycle') return;
const { type, runId, clientId } = event.event;
if (type === 'start') live.set(runId, clientId);
if (type === 'end') live.delete(runId);
});The map holds what the client has observed since it subscribed, which is not the same as everything alive on the conversation. A device that joined mid-answer never saw that run start, so its map is legitimately shorter than another's. Page history on attach to recover the starts you missed.
A suspended run stays in the map above, because nothing ended it. That is right for cancel-before-send and wrong for a Stop button, so track the suspend and resume events too if your interface has to separate the two.
Cancel one run without touching the others
A cancel carries one run id:
1
2
3
// Client-side.
await transport.cancel(await summary.runId);
// The risks run carries on streaming.Cancelling only the runs this client started is a filter over the run ids this client's own publishInput calls resolved:
1
2
3
// Client-side.
const mine = [...live].filter(([, clientId]) => clientId === myClientId).map(([runId]) => runId);
await Promise.all(mine.map((runId) => transport.cancel(runId)));Cancellation covers the rest, including the agent-side authorisation hook.
Wait for a run to finish
publishInput returns a runId promise that resolves when the agent's run start for your input arrives. The end is a lifecycle event, so waiting for it is a subscription:
1
2
3
4
5
6
7
8
9
10
11
12
13
// Client-side.
function whenRunEnds(transport, runId) {
return new Promise((resolve) => {
const off = transport.subscribe((event) => {
if (event.kind === 'run-lifecycle' && event.event.type === 'end' && event.event.runId === runId) {
off();
resolve(event.event.reason);
}
});
});
}
const reason = await whenRunEnds(transport, await summary.runId);subscribe returns its own unsubscribe function, so a waiter cleans up after itself rather than accumulating handlers per run.
With a durable session
At this level two concurrent runs are two independent streams, and your UI keeps them apart by keying on meta.runId. A durable session goes further and interleaves them into one conversation: each run becomes a node in the conversation tree under the input that triggered it, so two answers to two prompts are siblings rather than two lists you lay out yourself.
A client can then select a branch. With parallel runs on a tree, a view picks one path through the tree's branches, and switching path is a selection rather than a re-render of state you assembled.
What each client enumerates is scoped to its own view. runs() is filtered by that view's pagination window, branch selection, and regenerate substitution, so a client that hydrated partial history or joined mid-conversation lists fewer runs than one holding the whole conversation. It answers what this view can see, so a session-wide count needs a source that is not scoped to one branch.
Use cases
Interruption
Cancel what is live, then publish the replacement:
1
2
3
4
5
6
7
8
// Client-side.
await Promise.all([...live.keys()].map((runId) => transport.cancel(runId)));
const replacement = await transport.publishInput({
kind: 'message',
payload: { id: crypto.randomUUID(), role: 'user', parts: [{ type: 'text', text: 'Actually, focus on the budget instead' }] },
});
await wakeAgent(replacement.eventId);Interruption and steering covers the alternatives, including steering the run rather than replacing it.
Several people in one conversation
Two users prompting the same conversation from their own devices produce two runs. Neither client coordinates with the other, and both see both:
1
2
3
// Client-side, on each device. Same channel, different clientId.
const sent = await transport.publishInput({ kind: 'message', payload: prompt });
await wakeAgent(sent.eventId);Each run's start event carries the agent's own clientId, and that is an empty string when the agent supplied none. To label whose question is being answered, your UI can read the asking device's id from the triggering input's meta.clientId; the run's output events carry an input-codec-message-id that points back at that input.
Multi-agent
An orchestrator dispatches work to sub-agents, each streaming onto the same channel. Every sub-agent opens its own run, so their output multiplexes by run id with no coordination between them:
1
2
3
4
5
6
7
8
9
10
// Agent-side, in a sub-agent's endpoint.
const transport = createAgentTransport({ channel, codec, clientId: 'research-agent' });
await transport.connect();
// No inputCodecMessageId: this run answers the orchestrator rather than a
// client input, so no client is waiting on its run id.
const run = transport.openRun();
await run.pipe(researchStream());
await run.end({ reason: 'complete' });
transport.close();Let each sub-agent generate its own run id, which is what openRun does when you pass none. Sharing one run id across sub-agents collides their output on a single run.
Edge cases and unhappy paths
- Concurrent runs share the channel's message rate. A burst of parallel streams approaches the per-connection rate limit faster than a single stream, so tune the append rollup window to bring the publish rate down.
transport.cancel(runId)against a run that has already ended is a no-op. A cancel for a run no agent recognises is dropped rather than raised.- A multi-agent setup must let each sub-agent's
openRungenerate its own run id. Passing a shared id across sub-agents collides their output on one run, andopenRunalready generates a fresh one when you pass none. - Two clients publishing at the same time produce two runs whose output interleaves on the channel. Group what you render by
meta.runId, or the two answers arrive as one.
FAQ
How many turns run concurrently?
There is no hard limit on the channel side. Practical limits come from your application's concurrency (server compute, model rate limits) and the channel's message rate. Plan for the publish rate rather than the turn count.
Does the client need to track run ids?
Yes, at this level. The transport holds no registry, so keeping the ids you care about is your bookkeeping over the lifecycle stream. Keep only what your interface acts on: a run you offer to cancel, or one you are waiting on. A durable session tracks them for you.
How do I tell which run a message belongs to?
Read meta.runId off the message event. The decoder lifts it from the wire headers, so you never parse extras yourself. A run-less input, such as a user prompt before any agent has answered it, carries no run id.
Can one user have two runs open?
Yes. clientId does not limit how many runs a client has in flight, and a cancel carries one run id, so targeting a single one is straightforward.
Why run ids rather than message ids?
A run is one unit of agent work that produces several messages. The run id groups all of them, so cancel and wait operate on the unit a user recognises as one answer.
Related features
- Cancellation: scoped cancel signals and server-side abort handling.
- Interruption and steering: steer the active run, cancel and re-prompt, or send alongside.
- Multi-device and fan-out: the same runs reaching every attached device.
- Runs and steps: the run ids that keep parallel runs apart.