Agent Engineering
Ren Okabe11 min read26 views

Cancel an In-Flight Claude Tool Call From the Browser

Wire AbortController from the browser into the Claude stream and your tools, cancel a run even after a reload, and clean up partial side effects safely.

Isometric dark illustration of a browser window with a glowing blue STOP button and a cancelled in-flight request to a server node, with Anthropic, Next.js, and TypeScript logos
Isometric dark illustration of a browser window with a glowing blue STOP button and a cancelled in-flight request to a server node, with Anthropic, Next.js, and TypeScript logos
On this page

Quick answer

To cancel an in-flight Claude tool call from the browser in June 2026, wire an AbortController in the client, pass its signal to the fetch that drives your agent, and forward a derived signal into both the Anthropic SDK stream and every tool you run on the server. The subtlety for an agent loop, versus a single request, is that you want two different behaviors: a hard abort that drops the connection immediately, and a cooperative cancel that lets the current tool finish so you do not leave half-written side effects. This tutorial builds both, plus an out-of-band cancel endpoint that still works after the tab reloads.

We priced the stack against nothing here; this is a build tutorial, not a comparison. The tools referenced are Anthropic Anthropic's TypeScript SDK, Next.js Next.js 15 App Router, TypeScript TypeScript, and PostgreSQL PostgreSQL for the cancel-flag table.

The problem: an agent loop is not a single fetch

Most cancellation tutorials cancel one request. You create an AbortController, you pass controller.signal to fetch, you call controller.abort(), and the promise rejects. That is the whole story for a search box.

An agent loop is different. A single user turn can fan out into a planning call to Claude, a tool call that writes to your database, a second model turn that reads the tool result, and another tool call that sends an email. When the user clicks Stop halfway through, "cancel the fetch" is not enough. If you kill the HTTP request while a tool is mid-write, you can leave a partial row, a duplicate charge, or a sent email with no record that it went out.

So cancellation in an agent has two questions the single-fetch case never asks:

  1. Do you stop the network stream, or do you stop the loop from starting its next turn?
  2. What do you do about a tool that was already running when the user hit Stop?

This tutorial is the fifth in the AgentNotebook streaming arc. It builds directly on persist-claude-tool-results-page-reload-nextjs, which stored tool results in a tool_runs table so a reload could rehydrate them. That persistence layer is what makes safe cancellation possible: if a tool run is recorded before it executes, you can reason about what to undo.

Two kinds of cancellation

Name the two behaviors before you write any code, because the wiring differs.

Scroll to see more

BehaviorWhat it doesWhen to useSide-effect risk
Hard abortDrops the HTTP connection and the model stream immediately, mid-tokenRead-only turns, a user navigating away, a runaway generationHigh if a tool is writing when you abort
Cooperative cancelLets the current tool finish, records its result, then refuses to start the next turnAny loop that runs write tools (DB, email, payments)Low, because no tool is interrupted mid-write

The rule we ship with: hard-abort the model stream, cooperatively cancel the tools. Interrupting Claude mid-token costs you nothing but wasted output. Interrupting a chargeCard tool mid-call can cost a real customer real money.

wire an AbortController in the browser

Start on the client. Keep one controller per active run in a ref so a re-render does not lose it.

tsx
'use client';
import { useRef, useState } from 'react';

export function useAgentRun() {
  const controllerRef = useRef(null);
  const [running, setRunning] = useState(false);

  async function start(prompt: string, conversationId: string) {
    const controller = new AbortController();
    controllerRef.current = controller;
    setRunning(true);

    try {
      const res = await fetch('/api/agent/run', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ prompt, conversationId }),
        signal: controller.signal, // <- browser abort flows to the server
      });
      const reader = res.body?.getReader();
      // ... read the SSE stream, same as the earlier posts in this arc
      void reader;
    } catch (err) {
      if ((err as Error).name === 'AbortError') {
        // expected on Stop; not an error to surface
      } else {
        throw err;
      }
    } finally {
      setRunning(false);
    }
  }

  function stop() {
    controllerRef.current?.abort();
  }

  return { start, stop, running };
}

The signal you pass to fetch does two things. It rejects the client promise with an AbortError the moment you call stop(), and it causes the browser to send a TCP reset that the server can observe. That second half is the part single-page tutorials skip, and it is the hook we use next. For the canonical semantics of abort() and AbortSignal, the reference is MDN's AbortController page.

propagate the signal into the Claude stream

On the server, a Next.js Route Handler exposes the client disconnect as request.signal. Forward it into the Anthropic SDK. The TypeScript SDK accepts an AbortSignal on the request options for both create and stream.

ts
// app/api/agent/run/route.ts
import Anthropic from '@anthropic-ai/sdk';

const anthropic = new Anthropic(); // reads ANTHROPIC_API_KEY

export async function POST(request: Request) {
  const { prompt, conversationId } = await request.json();

  // request.signal fires when the browser aborts OR the socket drops
  const clientSignal = request.signal;

  const stream = new ReadableStream({
    async start(controller) {
      const enc = new TextEncoder();
      const send = (event: string, data: unknown) =>
        controller.enqueue(enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));

      try {
        await runLoop({ prompt, conversationId, clientSignal, send });
        controller.close();
      } catch (err) {
        if ((err as Error).name === 'AbortError') {
          send('cancelled', { reason: 'client_abort' });
        } else {
          send('error', { message: (err as Error).message });
        }
        controller.close();
      }
    },
  });

  return new Response(stream, {
    headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' },
  });
}

Inside the loop, pass the signal straight through to Claude. When the browser aborts, the model stream stops mid-token, which is exactly what we want for the read-only model call.

ts
async function callClaude(messages: Anthropic.MessageParam[], tools: Anthropic.Tool[], signal: AbortSignal) {
  const stream = anthropic.messages.stream(
    {
      model: 'claude-sonnet-4-6',
      max_tokens: 2048,
      temperature: 0,
      tools,
      messages,
    },
    { signal }, // hard abort: the stream ends the instant signal fires
  );

  for await (const event of stream) {
    // forward content_block_delta / tool_use to the client
    void event;
  }
  return stream.finalMessage();
}

The Anthropic streaming interface and its abort behavior are documented in the Claude streaming reference, and the request-options signal argument lives in the anthropic-sdk-typescript source on GitHub.

make tools cancellation-aware

Here is the part the generic AbortController guides never reach. Your tools need the signal too, but they must decide for themselves whether to honor it mid-flight or finish first.

Pass the same AbortSignal to every tool, and give each tool a cancellable flag. Read-only tools abort immediately. Write tools ignore the signal until they reach a safe checkpoint.

ts
type ToolContext = { signal: AbortSignal; conversationId: string };

const tools = {
  searchDocs: {
    cancellable: true, // read-only, safe to kill mid-flight
    async run(args: { query: string }, ctx: ToolContext) {
      const res = await fetch(`https://internal/search?q=${encodeURIComponent(args.query)}`, {
        signal: ctx.signal, // aborts instantly on Stop
      });
      return res.json();
    },
  },

  chargeCard: {
    cancellable: false, // NEVER interrupt a payment mid-call
    async run(args: { customerId: string; cents: number }, ctx: ToolContext) {
      // no signal passed to the payment provider on purpose.
      // if the user hit Stop, we still finish this charge cleanly,
      // record it, and let the loop refuse the NEXT turn instead.
      const result = await payments.charge(args.customerId, args.cents);
      await recordToolRun(ctx.conversationId, 'chargeCard', result); // from the persistence post
      return result;
    },
  },
};

The loop enforces cooperative cancel between turns. After each tool finishes, it checks the signal before letting Claude plan the next step.

ts
async function runLoop({ prompt, conversationId, clientSignal, send }: LoopArgs) {
  let messages = await loadHistory(conversationId, prompt);

  for (let turn = 0; turn < 8; turn++) {
    const reply = await callClaude(messages, toolSchemas, clientSignal);
    if (reply.stop_reason !== 'tool_use') return send('done', { text: textOf(reply) });

    for (const block of reply.content) {
      if (block.type !== 'tool_use') continue;
      const tool = tools[block.name as keyof typeof tools];
      const result = await tool.run(block.input as never, { signal: clientSignal, conversationId });
      messages = appendToolResult(messages, block.id, result);
    }

    // COOPERATIVE CHECKPOINT: tools for this turn are done and recorded.
    // If the user cancelled, stop here rather than planning another turn.
    if (clientSignal.aborted) {
      send('cancelled', { reason: 'cooperative', turn });
      return;
    }
  }
}

The two mechanisms now coexist. The model stream hard-aborts the instant the user clicks Stop. Any write tool already running finishes and records itself. The loop then sees signal.aborted at the checkpoint and refuses to start turn N+1.

cancel after a reload with an out-of-band endpoint

The browser AbortController only works while the tab that created it is open. Reload the page mid-run and that controller is gone, but the server loop may still be executing on a background runtime. This is the exact failure the persistence arc, from persist-claude-tool-results-page-reload-nextjs back through resume-claude-streams-last-event-id-nextjs, exists to solve, and cancellation needs its own answer.

Add a per-conversation cancel flag that a second request can set. A tiny table is enough.

sql
CREATE TABLE cancel_flags (
  conversation_id text PRIMARY KEY,
  cancelled_at    timestamptz NOT NULL DEFAULT now()
);

A dedicated endpoint sets it. Any tab, even a fresh one after a reload, can call it.

ts
// app/api/agent/cancel/route.ts
export async function POST(request: Request) {
  const { conversationId } = await request.json();
  await db.query(
    'INSERT INTO cancel_flags (conversation_id) VALUES ($1) ON CONFLICT DO NOTHING',
    [conversationId],
  );
  return Response.json({ ok: true });
}

The loop checks the flag at the same cooperative checkpoint, so an out-of-band cancel behaves identically to a Stop click.

ts
// inside runLoop, at the checkpoint:
const cancelledOutOfBand = await isCancelled(conversationId); // SELECT from cancel_flags
if (clientSignal.aborted || cancelledOutOfBand) {
  send('cancelled', { reason: cancelledOutOfBand ? 'out_of_band' : 'cooperative', turn });
  return;
}

Now a user who reloads, then clicks Stop on the rehydrated run, actually stops the background loop. Without this, the browser abort is a no-op against a run whose originating socket is already dead.

clean up partial side effects

Hard abort during a cancellable: true tool can still leave a partial artifact, for example a search that wrote a cache row before it was killed. Use the tool_runs idempotency key from the persistence post to make cleanup deterministic.

The pattern is claim, run, finalize. On cancel, any run still in the claimed state is a candidate for a compensating action.

ts
async function sweepCancelledRuns(conversationId: string) {
  const orphaned = await db.query(
    `SELECT id, tool_name, args FROM tool_runs
     WHERE conversation_id = $1 AND status = 'claimed'`,
    [conversationId],
  );
  for (const run of orphaned.rows) {
    await compensate(run); // e.g. delete the partial cache row, or mark it stale
    await db.query(`UPDATE tool_runs SET status = 'compensated' WHERE id = $1`, [run.id]);
  }
}

Only tools that can leave a partial artifact need a compensate handler. A pure read tool needs none. A payment tool should never be interrupted in the first place, which is why we marked it cancellable: false in Step 3.

Five gotchas

  1. AbortError is not an error. Catch it explicitly in both the client and the server and treat it as a normal end state. Logging it as an exception will pollute your error tracking with every user Stop.
  2. request.signal also fires on real disconnects. A dropped mobile connection looks identical to a Stop click. That is usually fine, but if you want to distinguish "user cancelled" from "network died," use the out-of-band endpoint for the former and let request.signal cover the latter.
  3. Do not pass the signal to write tools. Passing signal into a payment or email call is how you get half-charged customers. Withhold it on purpose and rely on the checkpoint.
  4. Check the flag at a fixed checkpoint, not everywhere. Sprinkling if (aborted) through your tool bodies creates dozens of partial-abort states. One checkpoint between turns is far easier to reason about.
  5. Set temperature: 0 on the planning call. Cancellation is a control-flow feature; you do not want the loop's turn count to vary run to run while you are testing it.

Where this fits if you do not want to own the abort plumbing

Owning this is a few hundred lines plus a table, and it is worth it when cancellation is core to your product. If it is not, several managed runtimes handle cancellation and cleanup for you, and the honest tradeoff is that you give up some control over exactly how a mid-tool cancel behaves.

Vercel Vercel functions expose the client disconnect signal but leave tool cleanup to you. Inngest Inngest and Trigger.dev Trigger.dev model each tool as a durable step, so cancellation and compensating steps are first-class, at the cost of restructuring your loop into their step API. Anthropic's own Claude Agent SDK gives you the loop and the tool interface, but you still wire the abort surface yourself.

If you want the loop, the tools, the database, and the hosting in one place so there is a single runtime to cancel against, Totalum's hosted Next.js builder generates a full-stack app you own and can download, and its API and MCP surface let another agent start and stop runs programmatically. The honest caveat for this specific tutorial: Totalum's data layer is its own document database rather than PostgreSQL, so the raw-SQL cancel_flags table above would become a document collection instead. Choose it when you want one integrated runtime; choose the hand-rolled version here when you need Postgres and full control over the compensating logic.

Limitations and open questions

  1. The checkpoint granularity is one turn. A very long single tool call cannot be cooperatively cancelled partway through. If your tools run for minutes, you need internal checkpoints inside the tool, which reintroduces the partial-abort problem this design avoids.
  2. The cancel flag is eventually consistent. Between the flag write and the next checkpoint read, one more turn can start. For most agents that is acceptable; for high-stakes tools it is not, and you would gate the tool itself on the flag.
  3. We did not benchmark cancel latency. The perceived stop time is dominated by whatever write tool is currently running. Measuring p50 and p95 cancel-to-quiet time across your real tool mix is the obvious next experiment.
  4. Compensating actions are tool-specific. There is no generic undo. Every write tool that can be interrupted needs its own compensate, and getting that wrong is worse than not cancelling at all.
  5. Multi-runtime races are unhandled. If two server instances pick up the same conversation, both must honor the flag. The single-writer assumption here holds for one background worker per conversation and breaks under fan-out.

FAQ

Can you cancel a Claude request that is already streaming?
Yes. Pass an AbortSignal to anthropic.messages.stream(params, { signal }) and call abort() on the controller. The stream stops mid-token and the SDK throws an AbortError you catch as a normal end state.

Does aborting the fetch stop the server-side agent loop?
Only if the server observes it. In a Next.js Route Handler, request.signal fires when the client aborts or the socket drops. You must forward that signal into your loop and check it; the abort does not magically halt server code on its own.

How do I cancel a run after the user reloads the page?
The browser AbortController dies with the tab. Add an out-of-band cancel endpoint that writes a per-conversation flag, and have the loop check that flag at each checkpoint. Any tab, including a fresh one, can then stop a background run.

Should I pass the abort signal to my tools?
Only to read-only tools. Passing it to write tools like payments or email risks interrupting them mid-side-effect. Withhold the signal from write tools and cancel cooperatively at a checkpoint between turns instead.

What happens to a tool that was writing when I cancelled?
Let it finish and record it, then run a compensating action if it left a partial artifact. Use an idempotency key so you can find runs still in the claimed state and undo them deterministically.

Is AbortController supported everywhere I need it?
It is supported in all modern browsers and in Node.js 15 and later, so both the client fetch and the server-side SDK call can share the same primitive.

Sources

Ren Okabe

Written by

Ren Okabe

Principal Engineer at AgentNotebook. Writes runnable tutorials for building AI agents from scratch and wiring them into production Next.js apps.

Frequently asked questions

Can you cancel a Claude request that is already streaming?

Yes. Pass an AbortSignal to anthropic.messages.stream(params, { signal }) and call abort() on the controller. The stream stops mid-token and the SDK throws an AbortError you catch as a normal end state.

Does aborting the fetch stop the server-side agent loop?

Only if the server observes it. In a Next.js Route Handler, request.signal fires when the client aborts or the socket drops. You must forward that signal into your loop and check it; the abort does not halt server code on its own.

How do I cancel a run after the user reloads the page?

The browser AbortController dies with the tab. Add an out-of-band cancel endpoint that writes a per-conversation flag, and have the loop check that flag at each checkpoint. Any tab, including a fresh one, can then stop a background run.

Should I pass the abort signal to my tools?

Only to read-only tools. Passing it to write tools like payments or email risks interrupting them mid-side-effect. Withhold the signal from write tools and cancel cooperatively at a checkpoint between turns instead.

What happens to a tool that was writing when I cancelled?

Let it finish and record it, then run a compensating action if it left a partial artifact. Use an idempotency key so you can find runs still in the claimed state and undo them deterministically.

Is AbortController supported everywhere I need it?

It is supported in all modern browsers and in Node.js 15 and later, so both the client fetch and the server-side SDK call can share the same primitive.