Concurrent turns

Open in

Concurrent turns allow multiple request-response cycles to be active simultaneously on the same session. Each turn has its own stream, cancel handle, and lifecycle. Cancel one turn without affecting others. This enables interruption patterns, multi-user sessions, and multi-agent architectures.

How it works

Turns are multiplexed on the Ably channel via turnId. Every message published during a turn - text deltas, tool calls, lifecycle events - is tagged with a header identifying its turn. The client transport inspects these headers and routes each message to the correct turn's stream.

JavaScript

1

2

3

4

5

6

7

8

9

10

11

12

// Client sends two messages without waiting for the first to finish
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?' }] }])

// Each turn has its own stream
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:

JavaScript

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

// Server: each incoming turn is independent
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. Use it to show per-client streaming indicators:

JavaScript

1

2

3

4

5

6

7

8

9

10

const activeTurns = useActiveTurns(transport)
// Map<clientId, Set<turnId>>

// Show a spinner next to each client that's streaming
for (const [clientId, turnIds] of activeTurns) {
  console.log(`${clientId} has ${turnIds.size} active turn(s)`)
}

// Check if a specific agent is busy
const isAgentStreaming = activeTurns.has('agent-1')

The map updates in real time across all connected clients. When a turn starts or ends anywhere on the channel, every subscriber's useActiveTurns reflects the change immediately.

Scoped cancellation

Cancel one turn without affecting others by passing a turnId filter:

JavaScript

1

2

3

4

// Cancel a specific turn
await transport.cancel({ turnId: turn1.turnId })

// turn2 continues streaming

The default cancel behavior ({ own: true }) cancels all of your active turns. For concurrent turns, scoped cancellation is essential - it lets you stop one generation while others keep running.

FilterEffect
{ turnId }Cancel one specific turn
{ own: true }Cancel all turns started by this client
{ clientId }Cancel all turns by a specific client
{ all: true }Cancel every active turn on the channel

See Cancellation for the full cancel API including server-side authorization and abort hooks.

Wait for turns

transport.waitForTurn() returns a promise that resolves when matching turns complete. Use it to coordinate work that depends on a turn finishing:

JavaScript

1

2

3

4

5

6

7

8

// Wait for a specific turn to finish
await transport.waitForTurn({ turnId: turn1.turnId })

// Wait for all of your own turns to finish
await transport.waitForTurn({ own: true })

// Wait for all turns on the channel to complete
await transport.waitForTurn({ all: true })

This is useful when you need to sequence operations - for example, sending a follow-up message only after the first response is complete, or disabling a submit button until all pending turns resolve.

Use cases

Interruption

Cancel the current turn and immediately start a new one. Both turns exist briefly on the channel - the old turn winding down while the new turn starts streaming:

JavaScript

1

2

3

// User hits send while the agent is still responding
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 and barge-in for the full pattern.

Multi-user sessions

Two users prompting the same session simultaneously. Each user's turn is independent - both see each other's prompts and responses in real time:

JavaScript

1

2

3

4

5

6

7

// User A sends a message
const turnA = await viewA.send([{ id: crypto.randomUUID(), role: 'user', parts: [{ type: 'text', text: 'What does section 3 mean?' }] }])

// User B sends a message at the same time
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 its response concurrently on the same channel:

JavaScript

1

2

3

4

5

6

7

8

9

10

11

12

13

// Server: orchestrator fans out to sub-agents
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' })

  // Both sub-agents stream concurrently
  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.