Cancellation

Your users can stop an agent mid-response. A cancel is a signal on the channel scoped to one run, so other runs carry on and the conversation stays open.

Cancellation is a run-level operation. The client publishes a cancel for one run, the agent's matching run handle fires its abort signal, and the agent stops its own work and closes the run with reason 'cancelled'. Every subscribed client reads that run end event.

Diagram showing a cancel signal stopping the in-progress run

On the client, a minimal cancel:

JavaScript

1

await transport.cancel(runId);

How it works

The channel is bidirectional, so a cancel is an ordinary publish. transport.cancel(runId) is stateless: it publishes an ai-cancel envelope carrying that run's id and holds nothing.

The run id therefore has to come from somewhere. There are two sources, and which one you use decides how early you can cancel:

Where the run id comes fromAvailable from
publishInput's runId promiseResolves when the agent's run start for your input arrives.
A run-lifecycle event with type: 'start'The moment that run start reaches this client, whoever started it.

Both give you a run id only once the agent has opened its run. Users often press Stop in the gap before the agent has opened its run. The agent closes that gap from its side:

JavaScript

1

2

3

// Agent-side.
const trigger = await transport.locateInput(eventId);
const run = transport.openRun({ inputCodecMessageId: trigger?.meta.codecMessageId });

Passing inputCodecMessageId registers the run against the input that triggered it, so a cancel keyed on that input routes here. A cancel that arrived before this openRun ran is buffered and honoured the moment the run opens. Leave it out and only a cancel message that carries the run id reaches this run.

A cancel is idempotent. The envelope carries a per-cancel event id so a channel rewind can redeliver it, and the read side ignores that id, so a redelivered or repeated cancel fires the abort signal once.

Cancel from the client

Track the live run from the lifecycle stream, then cancel it by id:

JavaScript

1

2

3

4

5

6

7

8

9

10

// Client-side.
let activeRunId;

transport.subscribe((event) => {
  if (event.kind !== 'run-lifecycle') return;
  if (event.event.type === 'start') activeRunId = event.event.runId;
  if (event.event.type === 'end') activeRunId = undefined;
});

const stop = () => activeRunId && transport.cancel(activeRunId);

Cancelling several runs is a loop over the ids you are tracking. The transport holds no registry, so which runs your interface offers to cancel is yours to decide. A run-lifecycle event carries the clientId of whoever published ai-run-start, which is the agent, so scoping a Stop button to the runs this client started means keeping the run ids publishInput resolved for it.

Handle the cancel on the agent

Pass the abort signal into your work

The run handle exposes abortSignal, which fires when an accepted cancel routes to that run. Pass it into the model call, and into any tool work that can outlive it:

JavaScript

1

2

3

4

5

6

7

8

9

10

11

// Agent-side.
const run = transport.openRun({ inputCodecMessageId: trigger?.meta.codecMessageId });

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 });

An in-flight pipe ends 'cancelled' on its own when the signal fires, and the agent publishes the run's end itself. Ending the run is never automatic: a handler that returns without calling run.end leaves the run open on the channel and every client waiting on a stream that never finishes.

Authorise the cancel

openRun takes hooks as its second argument. onCancel decides whether a cancel is honoured:

JavaScript

1

2

3

4

5

6

7

// Agent-side.
const userId = await authenticateUser(req);

const run = transport.openRun(
  { inputCodecMessageId: trigger?.meta.codecMessageId },
  { onCancel: async (request) => request.message.clientId === userId },
);

CancelRequest carries the raw cancel message, including the requester's clientId, and the runId it targets. Ably verifies the publisher's clientId before the cancel reaches the agent, so the value is trustworthy. Return false to reject and the run continues. With no onCancel, every cancel is accepted.

A hook that throws rejects nothing. The transport routes the error to the run's onError hook, or to its own error stream when the run has no such hook, and the run carries on, so treat a throwing onCancel as a rejection you will only see in your logs.

Publish a final note on the way out

onCancelled runs when the abort fires, before the in-flight streams close, so you can publish a last event:

JavaScript

1

2

3

4

5

6

7

8

9

// Agent-side.
const run = transport.openRun(
  { inputCodecMessageId: trigger?.meta.codecMessageId },
  {
    onCancelled: async (write) => {
      await write({ type: 'text-delta', id: 'cancel-note', delta: '\n[Response cancelled]' });
    },
  },
);

With a durable session

A durable session holds a registry of the runs it has seen, so a client cancels without tracking ids itself:

JavaScript

1

2

3

4

5

6

7

// Client-side, with a durable session.
const { session } = useClientSession();
const { runs } = useView();

// A suspended run is still live: cancel it too, or it survives the next turn.
const live = runs().filter((run) => run.status === 'active' || run.status === 'suspended');
await Promise.all(live.map((run) => session.cancel(run.runId)));

The signal on the wire is the same. The session adds the bookkeeping: it observes every run start and end on the channel and exposes the result through runs(), so cancelling everything in flight is one call rather than state you maintain. runs() reports what that session has observed, so a client that joined mid-conversation may know about fewer runs than another.

Edge cases and unhappy paths

  • Cancellation is asynchronous. A few more tokens arrive after cancel() resolves and before the agent's abortSignal fires. They belong to the cancelled run; render them there rather than on the next one.
  • The agent is responsible for honouring the signal. A tool that never checks it runs to completion, and its output still reaches the channel.
  • onCancel authorises cancel messages only. An abort arriving through the run's signal, such as a request abort or a serverless function timeout, cancels the run without consulting it, so cleanup that must happen either way belongs in onCancelled.
  • A run suspended awaiting a client tool result is not terminal, so it still appears in runs(). Code that filters on status === 'active' alone skips it, and the run stays open until something resolves or cancels it.
  • A cancel from a client without the channel publish capability rejects the cancel() promise, so an await with no catch raises an unhandled rejection. Verify capabilities on the authentication endpoint.
  • An onCancel that returns false tells the requesting client nothing. Surface the rejection through your own protocol if the user needs to know.
  • transport.close() does not cancel anything. It stops delivery and leaves open runs open, so cancel first if a closing client should stop the work it started.
  • A cancel carrying only a run id this agent never opened is a no-op, and one carrying an input codec-message-id is buffered against that id and honoured if a run later opens against that input.

FAQ

Why a signal rather than closing the connection?

Closing the connection detaches this client and tells the agent nothing, because a drop and a deliberate close look the same from the other end. A cancel says stop, leaves the conversation intact, and reaches every attached device. Clients that drop mid-stream reconnect and resume instead.

Can a user on another device cancel my run?

Yes, if onCancel allows it. A cancel is a publish on a shared channel, so any client with publish capability can send one. The default accepts every request; scope it with the authorisation pattern above.

What happens if two cancel messages match the same run?

The run cancels once. The abort signal does not refire, and further matching envelopes are no-ops.

How do I tell a cancelled run from one that finished?

Read the reason off the run-lifecycle event with type: 'end'. It is 'cancelled' for a cancel and 'complete' for a normal finish. Do not infer it from tokens stopping.

Is a cancel billed as a message?

Yes. The cancel envelope is a published message on the channel, billed at the current message rates.