Cancellation
Your users can stop an agent mid-response without breaking the session. AI Transport sends cancel as a signal on the channel, so other turns continue and the session stays open.
Cancellation is a turn-level operation. The client publishes a cancel signal on the Ably channel; the server matches it against active turns and fires their abort signals. Unlike closing an HTTP connection, cancellation is an explicit signal: the session remains intact, other turns continue, and both sides handle cleanup gracefully.
A minimal cancel:
1
await transport.cancel();How it works
Sessions are bidirectional, so a cancel is just a signal on the channel. The client publishes a cancel message with a filter specifying which turns to cancel. The server's transport matches the filter against active turns and fires their abort signals. The LLM stream stops, the turn ends with reason 'cancelled', and every subscriber receives the lifecycle update.
1
2
3
4
5
6
7
// Client: cancel the current turn
await turn.cancel();
// Server: abort signal fires automatically
const result = streamText({
abortSignal: turn.abortSignal,
});Cancel filters
Cancel signals are scoped. You control which turns are cancelled:
| Filter | Effect | Use case |
|---|---|---|
{ own: true } (default) | Cancel all turns started by this client. | Stop button. |
{ turnId: 'abc' } | Cancel one specific turn. | Cancel a specific generation. |
{ clientId: 'user-1' } | Cancel all turns by a specific client. | Admin cancellation. |
{ all: true } | Cancel all turns on the channel. | Emergency stop. |
1
2
3
4
5
6
7
8
// Cancel your own turns (default)
await transport.cancel();
// Cancel a specific turn
await transport.cancel({ turnId: activeTurn.turnId });
// Cancel all turns on the channel
await transport.cancel({ all: true });Server-side handling
Abort signal
Every turn exposes an abortSignal that fires when the turn is cancelled. Pass it to your LLM call:
1
2
3
4
5
6
7
8
9
10
11
const turn = transport.newTurn({ turnId, clientId });
await turn.start();
const result = streamText({
model: anthropic('claude-sonnet-4-20250514'),
messages: history,
abortSignal: turn.abortSignal,
});
const { reason } = await turn.streamResponse(result.toUIMessageStream());
await turn.end(reason);reason is 'cancelled' when the abort fires.
Authorise the cancel
The onCancel hook authorises or rejects cancel requests:
1
2
3
4
5
6
7
8
const turn = transport.newTurn({
turnId,
clientId,
onCancel: async (request) => {
const owner = request.turnOwners.get(request.filter.turnId);
return owner === request.message.clientId;
},
});The CancelRequest includes message (the raw cancel message with clientId), filter (parsed scope), matchedTurnIds, and turnOwners (map of turn ID to owner client ID). Return false to reject. If onCancel is not provided, all cancel requests are accepted.
Hook into the abort
The onAbort hook runs after the abort signal fires, giving you a chance to publish final events before the stream closes:
1
2
3
4
5
6
7
const turn = transport.newTurn({
turnId,
clientId,
onAbort: async (write) => {
await write({ type: 'text-delta', textDelta: '\n[Response cancelled]' });
},
});Cancel on close
When a client transport closes, it optionally cancels its own turns:
1
await transport.close({ cancel: { own: true } });Edge cases and unhappy paths
- Cancellation is asynchronous. A few more tokens arrive after
cancel()returns and before the server'sabortSignalfires. Render them on the cancelled turn. - The server is responsible for honouring the abort signal. A tool invocation that does not check the signal continues to run until it completes.
- Cancel signals from a client without the channel publish capability will silently fail. Verify capabilities on the authentication endpoint.
- An
onCancelthat returnsfalsedoes not notify the requesting client. Surface the rejection through your own application protocol if the user needs to know. - A cancel sent before the turn starts is delivered to the channel and accumulated; the server applies it as soon as the turn is created.
FAQ
Why use cancel signals instead of closing the connection?
Closing the connection disconnects a client from the session. The session and connection are distinct and not coupled. A cancel signal notifies the agent to stop the stream but leaves the session intact, so the next message starts a new turn immediately, on every connected device. See reconnection and recovery for how clients that disconnect mid-stream can reconnect and resume.
Can a user on another device cancel my turn?
Yes, if your onCancel hook authorises it. The default accepts all cancel requests. See the authorisation pattern above to scope it to the turn owner.
What happens if multiple cancel signals match the same turn?
The turn cancels once. Subsequent matching signals are no-ops; the abort signal does not refire.
How do I tell a cancelled turn apart from one that finished normally?
turn.end(reason) reports the reason on the channel. Clients receive it through the view's turn-end event. The reason is 'cancelled' for a cancel and 'complete' for a normal finish.
Does cancel cost a message?
The cancel signal is a published message on the channel. See pricing for current rates.
Related features
- Interruption: cancel and immediately send a new message.
- Concurrent turns: multiple turns with independent cancel handles.
- Token streaming: what gets cancelled when the abort fires.