---
title: Cancellation
description: There is no abort() API. Cancelling is appending, and the running handler finds out through a signal.
---

## Cancelling is appending

```ts
// in the browser: push from useSession
push({ type: 'cancelled', payload: { lastSeenIndex: index } })
```

A user presses stop on a streaming AI response. That's an event: same route,
same auth, same log as every other event. Two things then happen, at two
speeds.

## The running handler stops

```ts server/chat.ts
import { createServer } from 'experimental-a2/server'
import { chat } from '@/contracts'

export const chatServer = createServer({
  contract: chat,
  handlers: {
    generate: {
      abortOn: ['cancelled'],
      handler: async (ctx) => {
        // your abortable work; hand it ctx.signal:
        // await streamText({ signal: ctx.signal, ... })
      },
    },
  },
})
```

`abortOn` names the events that interrupt the handler. When a `cancelled`
event lands, or had already landed before the handler started, `ctx.signal`
fires. Handlers without `abortOn` hold no subscription.

When one session runs several generations over its lifetime, match the
instance with a predicate:

```ts
// server/chat.ts, the generate entry, targeted by instance:
generate: {
  abortOn: {
    // only the cancel that names this run fires the signal
    cancelled: (event, trigger) => event.payload.of === trigger.id,
  },
  handler: async (ctx) => {
    // ...
  },
}
```

The pushing side already knows the id it's cancelling: it's in the hook's
`events` feed.

## When a run loses its claim

Every handler's signal, with or without `abortOn`, also fires when the run
loses its claim: `CLAIM_EXPIRED` when the lease lapses without renewal
(decided locally, so it works while the store is unreachable), or
`SUPERSEDED_ATTEMPT` when a recovery run has claimed the event. Either way, a
successor is taking over: stop external work. A superseded run can no longer
write to the log or complete; passing `ctx.signal` to your external calls is
what stops it racing its successor outside A2.

Pass `ctx.signal` to your I/O and let the abort propagate. That handles every
case:

- **A propagated lease abort** surrenders the attempt: no completion, and
  normal recovery. Like a crash, it costs the event one attempt of its
  failure budget.
- **A user cancellation** (`abortOn`) is the one to catch and return normally
  from. The stop is the outcome, so the completion counts.
- **Return normally after a lease abort** only when the work is done. A lapsed
  run that finishes before any successor claims the event still completes.
- **Any other throw** is a real failure and spends the retry budget.

## Clean up after the handler

A `cancelled` handler can run while the interrupted one is still unwinding:
the signal is the preemption channel, the event is the durable record. When
cleanup must wait, put both event types in one lane. The event still fires the
signal on append, but its own handler waits for earlier work in the lane:

```ts server/chat-with-cleanup.ts
import { createServer } from 'experimental-a2/server'
import { chat } from '@/contracts'

export const chatServer = createServer({
  contract: chat,
  handlers: {
    generate: {
      lane: 'generation',
      abortOn: ['cancelled'],
      handler: async (ctx) => {
        // your abortable work; hand it ctx.signal:
        // await streamText({ signal: ctx.signal, ... })
      },
    },
    cancelled: {
      lane: 'generation',
      handler: async () => {
        // your cleanup, after generate has returned
      },
    },
  },
})
```

## The view cuts

```ts reducer.ts
case 'chunk':
  return state.cancelled
    ? state
    : { ...state, chunks: [...state.chunks, { index: event.index, delta: event.payload.delta }] }
case 'cancelled':
  return {
    ...state,
    cancelled: true,
    chunks: state.chunks.filter((c) => c.index <= event.payload.lastSeenIndex),
  }
```

Chunks in flight during the cancel round trip still land after `cancelled`.
They're true history; keeping them out of the view is the reducer's job.

`lastSeenIndex` is the client's stream frontier, which it already tracks. The
cut is exact and clock-free: chunks the user never saw are removed, so the
optimistic view and the authoritative refold agree. Because the client pushes
`cancelled` [optimistically](/client/react#events-and-history), its own fold
cuts instantly, and stragglers hit `state.cancelled` and vanish.

:::tip
Keep chunks at per-chunk granularity in state until the session reaches a
terminal event, then compact them into one string. That's reducer design,
fully yours.
:::
