Cancellation

Your users can stop an agent mid-response without breaking the session. AI Transport sends cancel as a signal on the session, so other turns continue and the session stays open.

Cancellation is a turn-level operation. The client publishes a cancel signal on the session; the agent receives it on the run with the matching runId and fires its abort signal. Cancellation is an explicit signal, so the session remains intact and other runs continue while both sides clean up gracefully.

Diagram showing a cancel signal stopping the in-progress run

On the client, a minimal cancel:

JavaScript

1

await activeRun.cancel();

How it works

Sessions are bidirectional, so a cancel is just a signal on the session. The client publishes a cancel message keyed on the triggering input's codec-message-id (the synchronous handle the client owns from send time). Once the agent has resolved the cancel to a registered run, that run's abortSignal fires. The LLM stream stops, the run ends with reason 'cancelled', and every subscriber receives the lifecycle update. A cancel published before the agent has assigned the run-id is still honoured: the agent buffers it and fires once the input-event lookup resolves.

JavaScript

1

2

3

4

5

6

7

8

9

10

11

12

13

// Client: cancel the current run.
// activeRun.cancel() works immediately, even before the runId
// promise on activeRun has resolved.
await activeRun.cancel();

// Or, when you already have a resolved runId (for example from a RunInfo
// in view.runs()):
await session.cancel(someRunInfo.runId);

// Server: abort signal fires automatically
const result = streamText({
  abortSignal: run.abortSignal,
});

Cancel one run, several, or all

activeRun.cancel() targets the run the client just started. To cancel several runs, iterate the visible runs on the client and cancel each by id:

JavaScript

1

2

3

// Cancel all active runs in the visible view (Stop button)
const active = session.view.runs().filter((r) => r.status === 'active');
await Promise.all(active.map((r) => session.cancel(r.runId)));

Selecting which runs to cancel is application logic. RunInfo.clientId tells you the run owner, so you can scope a cancel to runs started by the current client, a specific user, or all visible runs.

Server-side handling

Abort signal

Every run exposes an abortSignal that fires when the run is cancelled. Pass it to your LLM call:

JavaScript

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

const run = session.createRun(invocation, {}, { signal: req.signal });

// Drain run.view for the full conversation to feed the model. run.messages
// is only this run's own turn.
while (run.view.hasOlder()) await run.view.loadOlder();
await run.start();

const conversation = run.view.getMessages().map(({ message }) => message);
const result = streamText({
  model: anthropic('claude-sonnet-4-20250514'),
  messages: await convertToModelMessages(conversation),
  abortSignal: run.abortSignal,
});

const { reason } = await run.pipe(result.toUIMessageStream());
await run.end({ reason });

reason is 'cancelled' when the abort fires.

Authorise the cancel

The onCancel hook on RunHooks authorises or rejects cancel requests:

JavaScript

1

2

3

4

5

6

const userId = await authenticateUser(req);

const run = session.createRun(invocation, {}, {
  signal: req.signal,
  onCancel: async (request) => request.message.clientId === userId,
});

CancelRequest carries the raw cancel message (with the requester's clientId) and the runId it targets. Resolve the authorised identity from the inbound HTTP request and compare against request.message.clientId; the Ably service verifies the publisher's clientId before the cancel reaches the agent, so the value is trustworthy. Return false to reject; the run continues. If onCancel is not provided, all cancel requests are accepted.

Publish a final note before cancelling

The onCancelled hook runs when the abort signal fires, giving you a chance to publish final events before the stream closes:

JavaScript

1

2

3

4

5

6

const run = session.createRun(invocation, {}, {
  signal: req.signal,
  onCancelled: async (write) => {
    await write({ type: 'text-delta', id: 'cancel-note', delta: '\n[Response cancelled]' });
  },
});

Cancel on close

ClientSession.close() is local-state-only: it does not cancel runs on the wire. On the client, cancel in-progress runs explicitly before closing:

JavaScript

1

2

3

const active = session.view.runs().filter((r) => r.status === 'active');
await Promise.all(active.map((r) => session.cancel(r.runId)));
await session.close();

Edge cases and unhappy paths

  • Cancellation is asynchronous. A few more tokens arrive after cancel() returns and before the server's abortSignal fires. 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 onCancel that returns false does 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 session 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. Clients that disconnect mid-stream 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 distinguish a cancelled run from one that finished normally?

run.end(reason) reports the reason on the session. Clients receive it through the view's run lifecycle 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, billed at the current message rates.