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 turnId. Every message published during a turn (text deltas, tool calls, lifecycle events) carries a header identifying its turn. The client transport reads these headers and routes each message to the correct turn's stream.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const turn1 = await view.send([
{ id: crypto.randomUUID(), role: 'user', parts: [{ type: 'text', text: 'Summarize the report' }] },
]);
const turn2 = await view.send([
{ id: crypto.randomUUID(), role: 'user', parts: [{ type: 'text', text: 'What are the key risks?' }] },
]);
for await (const chunk of turn1.stream) {
renderToPanel('summary', chunk);
}
for await (const chunk of turn2.stream) {
renderToPanel('risks', chunk);
}On the server, each turn is handled independently. The server transport creates a separate turn object per request, each with its own abort signal and lifecycle:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
app.post('/api/chat', async (req, res) => {
const { turnId, clientId, messages } = req.body;
const turn = transport.newTurn({ turnId, clientId });
await turn.start();
const result = streamText({
model: anthropic('claude-sonnet-4-20250514'),
messages,
abortSignal: turn.abortSignal,
});
await turn.streamResponse(result.toUIMessageStream());
await turn.end('complete');
res.json({ ok: true });
});Track active turns
useActiveTurns returns a map of which clients have which turns in progress:
1
2
3
4
5
6
7
const activeTurns = useActiveTurns({ transport });
for (const [clientId, turnIds] of activeTurns) {
console.log(`${clientId} has ${turnIds.size} active turn(s)`);
}
const isAgentStreaming = activeTurns.has('agent-1');The map updates in real time across every connected client. A turn that starts or ends anywhere on the channel updates every subscriber's useActiveTurns immediately.
Scope cancellation to a turn
Cancel one turn without affecting others by passing a turnId filter:
1
2
await transport.cancel({ turnId: turn1.turnId });
// turn2 continues streamingThe default cancel ({ own: true }) cancels every turn started by this client. For concurrent turns, scoped cancellation is essential.
| Filter | Effect |
|---|---|
{ turnId } | Cancel one specific turn. |
{ own: true } | Cancel every turn started by this client. |
{ clientId } | Cancel every turn by a specific client. |
{ all: true } | Cancel every active turn on the channel. |
See cancellation for the full cancel API, including server-side authorisation and abort hooks.
Wait for turns to complete
transport.waitForTurn() returns a promise that resolves when matching turns complete:
1
2
3
4
5
await transport.waitForTurn({ turnId: turn1.turnId });
await transport.waitForTurn({ own: true });
await transport.waitForTurn({ all: true });Use this to sequence work: send a follow-up only after the first response completes, or disable a submit button until pending turns resolve.
Use cases
Interruption
Cancel the current turn and immediately start a new one:
1
2
3
4
await transport.cancel({ own: true });
const newTurn = await view.send([
{ 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 turn is independent:
1
2
3
4
5
6
7
const turnA = await viewA.send([
{ id: crypto.randomUUID(), role: 'user', parts: [{ type: 'text', text: 'What does section 3 mean?' }] },
]);
const turnB = await viewB.send([
{ id: crypto.randomUUID(), role: 'user', parts: [{ type: 'text', text: 'Summarize section 5' }] },
]);Both turns stream concurrently on the shared channel.
Multi-agent
An orchestrator dispatches work to multiple sub-agents, each streaming concurrently on the same channel:
1
2
3
4
5
6
7
8
9
10
11
app.post('/api/chat', async (req, res) => {
const { messages } = req.body;
const researchTurn = transport.newTurn({ turnId: 'research', clientId: 'researcher' });
const analysisTurn = transport.newTurn({ turnId: 'analysis', clientId: 'analyst' });
await Promise.all([
runResearchAgent(researchTurn, messages),
runAnalysisAgent(analysisTurn, messages),
]);
res.json({ ok: true });
});The client sees both agent responses arriving in parallel, each tagged with its own turn ID and client ID.
Edge cases and unhappy paths
- Concurrent turns 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.
- A scoped cancel against a
turnIdthat no longer matches an active turn is a no-op. Cancellation does not error on absence. waitForTurn({ all: true })includes turns started by other clients. If you only want to wait for your own, use{ own: true }.- A multi-agent setup with the same
turnIdacross sub-agents collides on the channel. Generate a uniqueturnIdper sub-agent. - Two clients sending at the same time produce two turns. 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 turn IDs?
The transport tracks turn IDs internally. Hold onto the turn handle returned by send() only if you need to cancel it specifically or wait for it specifically.
How do I tell which turn a message belongs to?
Each message carries its turnId in the header. useView's tree exposes the relationship; 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 turn IDs instead of message IDs?
A turn is one unit of agent work that produces multiple messages. The turn ID 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.