---
title: Cancellation
description: There is no abort() API. Cancelling is appending, and the running consumer 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 consumer stops

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

const generate = consumer({
  contract: chat,
  name: 'generate',
  types: ['generate'],
  abortOn: ['cancelled'],
}).run(async (ctx) => {
  // your abortable work; hand it ctx.signal:
  // await streamText({ signal: ctx.signal, ... })
})

export const chatServer = createServer({
  contract: chat,
  consumers: [generate],
})
```

`abortOn` names the events that interrupt the run. When a `cancelled` event
lands, or had already landed before the run started, `ctx.signal` fires.
Consumers 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 consumer, targeted by instance:
import { consumer } from 'experimental-a2/server'
import { chat } from '@/contracts'

const generate = consumer({
  contract: chat,
  name: 'generate',
  types: ['generate'],
  abortOn: {
    // only the cancel that names this run fires the signal
    cancelled: (event, trigger) => event.payload.of === trigger.id,
  },
}).run(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 consumer'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 run

A consumer of `cancelled` can run while the interrupted run is still
unwinding: the signal is the preemption channel, the event is the durable
record. When cleanup must wait, take both event types in one consumer with a
string lane. The `cancelled` event still fires the signal on append, but runs
only after earlier work in the lane:

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

const generation = consumer({
  contract: chat,
  name: 'generation',
  types: ['generate', 'cancelled'],
  lane: 'generation',
  abortOn: ['cancelled'],
}).run(async (ctx) => {
  if (ctx.event.type === 'generate') {
    // your abortable work; hand it ctx.signal:
    // await streamText({ signal: ctx.signal, ... })
    return
  }
  // your cleanup, after generate has returned
})

export const chatServer = createServer({
  contract: chat,
  consumers: [generation],
})
```

## 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.
:::
