Concurrent turns
Your application runs multiple AI request-response cycles on one session at the same time. AI Transport multiplexes turns over a single session; 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 session 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.
On the client, send two prompts and track each run separately:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const run1 = await view.send(createUIMessageCodec().createUserMessage({
id: crypto.randomUUID(),
role: 'user',
parts: [{ type: 'text', text: 'Summarize the report' }],
}));
const run2 = await view.send(createUIMessageCodec().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
21
22
23
24
25
26
app.post('/api/chat', async (req, res) => {
const invocation = Invocation.fromJSON(await req.json());
const session = createAgentSession({ client: ably, channelName: invocation.sessionName, codec: createUIMessageCodec() });
await session.connect();
const run = session.createRun(invocation, {}, { signal: req.signal });
// Rebuild the conversation from run.view before run.start(): draining pages in
// this run's triggering input (otherwise run.start() awaits it arriving live).
while (run.view.hasOlder()) {
await run.view.loadOlder();
}
const conversation = run.view.getMessages().map(({ message }) => message);
await run.start();
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 });
await session.end();
res.json({ ok: true });
});Track active runs
On the client, 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 realtime across every connected client. A run that starts or ends anywhere on the session 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)));Cancellation covers the full cancel API, including the agent-side authorisation hooks.
Wait for runs to complete
Each ClientRun exposes a synchronous runId (empty until ai-run-start is observed) and a started promise that resolves once it is. To wait for a run to terminate, await started, read runId, 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
19
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(createUIMessageCodec().createUserMessage({
id: crypto.randomUUID(),
role: 'user',
parts: [{ type: 'text', text: 'Hello' }],
}));
await run1.started;
const run1Id = 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
On the client, 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(createUIMessageCodec().createUserMessage({
id: crypto.randomUUID(),
role: 'user',
parts: [{ type: 'text', text: 'Actually, focus on the budget instead' }],
}));Interruption and steering covers the full pattern.
Multi-user sessions
Two users prompting the same session at the same time, each from their own client. Each run is independent:
1
2
3
4
5
6
7
8
9
10
11
const runA = await viewA.send(createUIMessageCodec().createUserMessage({
id: crypto.randomUUID(),
role: 'user',
parts: [{ type: 'text', text: 'What does section 3 mean?' }],
}));
const runB = await viewB.send(createUIMessageCodec().createUserMessage({
id: crypto.randomUUID(),
role: 'user',
parts: [{ type: 'text', text: 'Summarize section 5' }],
}));Both runs stream concurrently on the shared session.
Multi-agent
An orchestrator dispatches work to multiple sub-agents, each streaming concurrently on the same session. 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: createUIMessageCodec() });
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 receives both agent responses 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, so tune the append rollup window to bring the publish rate down.
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
createRungenerate its ownrunId; do not pass a sharedRunRuntime.runIdacross sub-agents or their messages will collide on the session. The default behaviour (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 rather than the turn count.
Does the client need to track run IDs?
The session tracks run identity internally. Hold onto the ClientRun 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 and steering: steer the active run, cancel and re-prompt, or send alongside.
- Multi-device sessions: concurrent turns across devices.