Concurrent turns
Your application runs multiple AI request-response cycles on one session at the same time. AI Transport multiplexes turns over a single channel; each turn has its own stream, cancel handle, and lifecycle.
Concurrent turns let multiple request-response cycles run at the same time on one session. Each turn has its own stream, cancel handle, and lifecycle. This is what makes interruption, multi-user sessions, and multi-agent architectures possible.
How it works
Turns are multiplexed on the Ably channel via runId. Every message published during a Run (text deltas, tool calls, lifecycle events) carries a header identifying its Run. The client session reads these headers and routes each message to the correct Run's stream.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const run1 = await view.send(UIMessageCodec.createUserMessage({
id: crypto.randomUUID(),
role: 'user',
parts: [{ type: 'text', text: 'Summarize the report' }],
}));
const run2 = await view.send(UIMessageCodec.createUserMessage({
id: crypto.randomUUID(),
role: 'user',
parts: [{ type: 'text', text: 'What are the key risks?' }],
}));
// Subscribe to lifecycle events on the session's tree to observe completion:
const off = session.tree.on('run', (event) => {
if (event.runId === run1.runId && event.type === 'end') renderToPanel('summary', 'done');
if (event.runId === run2.runId && event.type === 'end') renderToPanel('risks', 'done');
});On the agent side, each Run is handled independently. The agent session creates a separate Run per invocation, each with its own abort signal and lifecycle:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
app.post('/api/chat', async (req, res) => {
const invocation = Invocation.fromJSON(await req.json());
const session = createAgentSession({ client: ably, channelName: invocation.sessionName, codec: UIMessageCodec });
await session.connect();
const run = session.createRun(invocation, { signal: req.signal });
await run.start();
await run.loadConversation();
const result = streamText({
model: anthropic('claude-sonnet-4-20250514'),
messages: run.messages,
abortSignal: run.abortSignal,
});
const { reason } = await run.pipe(result.toUIMessageStream());
await run.end({ reason });
session.close();
res.json({ ok: true });
});Track active runs
session.view.runs() returns the visible Runs as projection-free RunInfo snapshots. Filter by status and clientId:
1
2
3
4
5
6
7
8
const { session } = useClientSession();
const runs = session.view.runs();
for (const run of runs.filter((r) => r.status === 'active')) {
console.log(`${run.clientId} has active run ${run.runId}`);
}
const isAgentStreaming = runs.some((r) => r.status === 'active' && r.clientId === 'agent-1');The view updates in real time across every connected client. A Run that starts or ends anywhere on the channel updates every subscriber's view immediately.
Cancel one run without touching the others
Cancel via the handle the client owns synchronously:
1
2
await run1.cancel();
// run2 continues streamingTo cancel all your active Runs (Stop button), filter session.view.runs() first:
1
2
3
const myClientId = ably.auth.clientId;
const myActive = session.view.runs().filter((r) => r.status === 'active' && r.clientId === myClientId);
await Promise.all(myActive.map((r) => session.cancel(r.runId)));See cancellation for the full cancel API, including agent-side authorisation hooks.
Wait for runs to complete
Each ActiveRun exposes runId: Promise<string> (resolves with the agent-minted id once ai-run-start is observed). To wait for a Run to terminate, await the id and then subscribe to the session tree's run events:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function whenRunEnds(session, runId) {
return new Promise((resolve) => {
const off = session.tree.on('run', (event) => {
if (event.runId === runId && event.type === 'end') {
off();
resolve(event.reason);
}
});
});
}
const run1 = await view.send(UIMessageCodec.createUserMessage({
id: crypto.randomUUID(),
role: 'user',
parts: [{ type: 'text', text: 'Hello' }],
}));
const run1Id = await run1.runId;
await whenRunEnds(session, run1Id);Use this to sequence work: send a follow-up only after the first response completes, or disable a submit button until the run resolves.
Use cases
Interruption
Cancel the current turn and immediately start a new one:
1
2
3
4
5
6
7
8
const active = session.view.runs().filter((r) => r.status === 'active');
await Promise.all(active.map((r) => session.cancel(r.runId)));
const newRun = await view.send(UIMessageCodec.createUserMessage({
id: crypto.randomUUID(),
role: 'user',
parts: [{ type: 'text', text: 'Actually, focus on the budget instead' }],
}));See Interruption for the full pattern.
Multi-user sessions
Two users prompting the same session at the same time. Each user's Run is independent:
1
2
3
4
5
6
7
8
9
10
11
const runA = await viewA.send(UIMessageCodec.createUserMessage({
id: crypto.randomUUID(),
role: 'user',
parts: [{ type: 'text', text: 'What does section 3 mean?' }],
}));
const runB = await viewB.send(UIMessageCodec.createUserMessage({
id: crypto.randomUUID(),
role: 'user',
parts: [{ type: 'text', text: 'Summarize section 5' }],
}));Both Runs stream concurrently on the shared channel.
Multi-agent
An orchestrator dispatches work to multiple sub-agents, each streaming concurrently on the same channel. Each sub-agent receives its own Invocation with a unique runId:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
app.post('/api/chat', async (req, res) => {
const invocation = Invocation.fromJSON(await req.json());
const session = createAgentSession({ client: ably, channelName: invocation.sessionName, codec: UIMessageCodec });
await session.connect();
// The orchestrator creates additional Invocations for sub-agents and POSTs
// to their endpoints. Each sub-agent runs its own session.createRun(...)
// against the same channel; their messages multiplex by runId.
const researchRun = session.createRun(invocation, { signal: req.signal });
await researchRun.start();
await runResearchAgent(researchRun);
res.json({ ok: true });
});The client sees both agent responses arriving in parallel, each tagged with its own runId and clientId.
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. See token streaming for rollup behaviour.
session.cancel(runId)against arunIdthat no longer matches an active Run is a no-op. Cancellation does not error on absence.- A multi-agent setup must let each sub-agent's
createRunmint its ownrunId; do not pass a sharedRunRuntime.runIdacross sub-agents or their messages will collide on the channel. The default minting path (onecrypto.randomUUID()percreateRun) is already unique. - Two clients sending at the same time produce two Runs. The conversation tree shows both as siblings of the same parent message.
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, not the turn count.
Does the client need to track run IDs?
The session tracks Run identity internally. Hold onto the ActiveRun returned by view.send() only if you need to cancel it specifically or wait for it specifically.
How do I tell which run a message belongs to?
Each message carries its run-id in extras.ai.transport. The session's tree exposes the relationship through view.runOf(codecMessageId); you do not parse headers manually.
Can two turns run on behalf of the same user?
Yes. The clientId does not constrain how many turns a client has open. Scoped cancellation lets you target one of them.
Why use run IDs instead of message IDs?
A Run is one unit of agent work that produces multiple messages. The runId groups every message the agent publishes for that work, so cancel and wait operate on the unit a user understands.
Related features
- Cancellation: scoped cancel signals and server-side abort handling.
- Interruption: cancel and immediately send a new message.
- Multi-device sessions: concurrent turns across devices.