跪拜 Guibai
← Back to the summary

Claude Code's Async Tools: Turn Waiting Test Runs Into Task IDs and Notifications

Why Is Your Claude Code Still Spinning and Waiting, While Someone Else's Has Already Finished Three Things?

Author: LEE

Tags: Frontend, Backend, AI Programming

When the same Claude Code is asked to run the full test suite, one person stares at the terminal spinning for 8 minutes and can't do anything else; another person's Claude Code gets a task ID, then turns to editing other modules, checking logs, and writing unit tests. When the tests finish, a notification drops into the conversation, and it comes back to fix the failing cases.

The difference is not the model; it's whether the async tools are used correctly.

I initially thought this was just “running a command in the background.” It wasn't until I captured and compared the request bodies of Claude Code 2.1.303 and 2.1.304 that I realized: tools went from 20 to 12, the system prompt is 5,768 characters, and the rest of the request body didn't change a single character.

Claude Code is changing a portion of tool calls from “function calls” to “submitting tasks.” In frontend terms, it's like changing a synchronous blocking call into a Promise: tool_use first returns a task ID, and when the work finishes, the runtime pushes the result notification back. Going further than a Promise: these tasks can keep pushing multiple events (Monitor), and can be canceled at any time by ID (TaskStop).

This post doesn't discuss vague concepts. It directly breaks down these 5 background modes, and also covers when you definitely shouldn't use them, because if you choose wrong, the waiting time you saved will be paid back twice over in your token bill.

This article is based on the behavior of Claude Code 2.1.303–2.1.305; version differences are noted in the text. The packet capture also revealed another layer of async (the change in ToolSearch defaults), covered in the appendix; skip it if you don't care about version details.

TL;DR

  1. Async tools are not “running commands in the background”; they are “the tool returns a task ID and calls you when it's done.”
  2. Five forms: background Bash, background Agent, Monitor, Cron / ScheduleWakeup, Workflow.
  3. Most scenarios only need the first two; Workflow is the last step, not the first.
  4. The two most underestimated costs: prompt cache (model-side context cache) and permission boundaries.

First, distinguish the two kinds of async: they solve different problems

Claude Code has two things called “async,” but they don't solve the same problem.

Dimension Definition async (ToolSearch, lazy loading) Execution async (background tasks)
What is async The tool's JSONSchema The tool's execution and result
Trigger Model calls ToolSearch run_in_background or task-based tools
Returns Complete schema Task ID / handle
Completion signal Returned within the same call A new round of events
What it saves Context tokens Wall-clock time
Main cost One extra search round trip State, permissions, cost, lifecycle

In one sentence: the former is lazy loading; the latter is task scheduling.

If you mix them up, you end up asking questions like “Can ToolSearch make tools run in parallel?” It can't. It is itself a synchronous call; it only changes schema retrieval from “inject everything at startup” to “one round trip when needed.”

Why does it need to be split into two steps?

A synchronous tool call is a blocking RPC: the model initiates Bash, the harness (the runtime/scheduling layer outside the model) executes it, the result comes back into context, and only then can the model continue. During that time the model can't do anything else, and the user can only wait.

Claude Code's default timeout for foreground Bash is 120 seconds, maximum 600 seconds. But the more interesting part is what happens after the timeout: the command doesn't simply fail; it gets moved to the background — unless the command starts with sleep (sleep is itself waiting, so moving it to the background is pointless), or background tasks are disabled.

This design shows one thing: the proper destination for a long task is not a longer timeout, but a task queue.

And so we get the 5 forms below. They aren't 5 features; they are 5 entrances to the same task model.

5 forms, see the whole table first

Form Representative tool Returns immediately Wake signal Cancel
Background command (Bash) Bash + run_in_background Task ID Process exits, re-invokes model TaskStop
Background subagent (Agent) Agent Agent ID Completion notification TaskStop / name / agent ID
Event monitoring (Monitor) Monitor Monitor handle Each line of output / each message TaskStop
Scheduled task (Cron / ScheduleWakeup) CronCreate / ScheduleWakeup Task ID Trigger a round when time arrives CronDelete / stop: true
Script orchestration (Workflow) Workflow Task ID task-notification TaskStop

Agent Teams is not in the table: it's collaboration between multiple sessions, not a single-task async form; it appears as a comparison in the selection table.

Below, ordered from light to heavy, each with a minimal call and its applicable boundary.

1. Background Bash: hand the process to the task table

A model-side call looks like this:

{
  "name": "Bash",
  "input": {
    "command": "npm test -- --runInBand",
    "description": "Run the full test suite",
    "run_in_background": true
  }
}

The full chain from call to result:

image.png

Figure 1: The complete chain for background Bash. The call immediately returns a task ID + output path; after the process exits, the harness injects a notification, and the model uses Read to fetch the output.

Three details:

Background tasks also have a full set of lifecycle constraints:

Constraint Value Description
Output limit 5GB A single task exceeding this is terminated
Session exit Auto cleanup The task ends when Claude Code exits
Memory pressure Idle ≥ 30 minutes and no turn running macOS / Linux stop such tasks
Unattended time limit Default 30 minutes, maximum 2 hours -p (non-interactive), CI, cloud sessions
-p + text prompt Default 10 minutes One-shot task, no follow-up interaction
Local interactive No limit while the session lives But exiting Claude Code still cleans up

2. Background Agent: by default it doesn't wait for you

The Agent tool creates subagents. Its run_in_background description says a lot about the design intent:

Subagents run in the background by default; you'll be notified when one completes.

Only when “the next step depends entirely on this result, and there's nothing else to do in the meantime” does it recommend passing false. In other words, a foreground subagent is the exception; background is the default.

Its difference from a background command is in the return value: the product of a command is an output file; the product of a subagent is a final report. The tool description specifically reminds you: the subagent's final report is not shown to the user; the main conversation needs to relay the key conclusions. Completed subagents can be continued by name with SendMessage, and the context is still there; opening a new Agent starts from scratch.

Isolation is also built into the parameters:

The biggest pitfall of background subagents is not the concurrency count; it's ownership of the result. The tool description contains a hard constraint:

Never fabricate or predict a pending agent's results — the notification is never something you write yourself.

The model cannot “guess” a result that hasn't come back, and it cannot write a completion notification itself. The notification must be injected by the harness. This constraint blocks the “hallucinated completion” that async systems are most prone to, at the protocol layer.

3. Monitor: waiting not for the end, but for change

A background command waits for process exit; Monitor (event monitoring) waits for each line of output. The tool description defines it as “run a command in the background and feed each line of output back to Claude,” and it can also connect directly to a WebSocket, treating every message as an event.

This solves a different class of problem: not “how long will this task run,” but “when does this thing change.” Tailing logs, polling CI, watching directories, tracking long scripts, all fall into this category.

Three easily overlooked limitations:

I set both DISABLE_TELEMETRY=1 and CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1, and Monitor was absent from the tool lists in both 2.1.303 and 2.1.304; the docs and the actual test agree. In enterprise environments with telemetry off, Monitor can't be used.

4. Cron / ScheduleWakeup: turn “the future” into a task too

CronCreate (scheduled task) accepts standard 5-field cron, interpreted in the local timezone:

{
  "name": "CronCreate",
  "input": {
    "cron": "7 * * * *",
    "prompt": "check the deploy status and report failures",
    "recurring": true,
    "durable": false
  }
}

Several very “engineering” constraints:

Jitter and “don't schedule all tasks at :00” come from the same consideration: Claude instances around the world hitting the API at the same time benefits no one; the tool description even suggests the model actively choose a minute that isn't 0 or 30.

The other path is ScheduleWakeup, which serves the dynamic rhythm of /loop: each time the model wakes up, it decides when to check next, with the delay constrained to 60–3600 seconds. CronCreate is for user-given time points; ScheduleWakeup is for scenarios like “watch this PR” where the model decides the rhythm.

5. Workflow: write the orchestration itself as a script

Workflow is the heaviest of the 5. Instead of deciding the next step turn by turn in the conversation, it executes a JavaScript script, using agent(), parallel(), pipeline(), and phase() to orchestrate subagents. The official example in the tool description (excerpted; DIMENSIONS and several schema definitions are omitted):

const results = await pipeline(
  DIMENSIONS,
  d => agent(d.prompt, { phase: 'Review', schema: FINDINGS_SCHEMA }),
  review => parallel(review.findings.map(f => () =>
    agent(`Verify: ${f.title}`, { phase: 'Verify', schema: VERDICT_SCHEMA })
  ))
)

The key to this script is not the syntax; it's that intermediate results live in script variables, not in the model context. The conversation only receives the final report. The tool description says it plainly:

Workflows run in the background — this tool returns immediately with a task ID, and a <task-notification> arrives when the workflow completes.

It also has three thresholds:

Agent Teams is off by default and needs CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1; it solves multiple sessions discussing with each other, not single-task async.

From the scheduler's perspective: what makes async tools hard

Put the 5 forms together, and looking at external behavior, Claude Code's main loop is no longer “model calls tool, tool returns result”; it's an event loop with a task table:

image.png

Figure 2: Abstracting the 5 forms into a task table. Synchronous calls go through tool_result; async calls enter a task registry, and when complete, come back through an event queue as system-reminder / task-notification. In the figure, Esc interrupts the current turn but does not cancel background tasks; use TaskStop to cancel a background task.

From this view, the design of async tools can be condensed into four things: a tool result returns a handle instead of a payload; completion events are injected by the harness and the model cannot fabricate them; cancellation and deadlines are first-class citizens; output goes to disk, and permissions stay in the foreground. The details of each appeared in the earlier forms; if you write your own Harness, there is an implementation checklist at the end.

The part most worth copying: write cache into the scheduler

In the ScheduleWakeup tool description, there is a long passage about “how long to wait,” and the core is not cron semantics; it's cost:

This session's requests use the default 5-minute Anthropic prompt-cache TTL. Sleeping past 300 seconds means the next wake-up reads your full conversation context uncached — slower and more expensive.

Then it gives three suggestions, plus one point to avoid:

Suggested wait Cache status Typical scenario
60–270 seconds Hit Actively poll external state: CI, deploy, remote queue
300 seconds Worst Don't choose: you pay the cache miss without amortizing it
300–3600 seconds Miss No need to check earlier, or as a long fallback
1200–1800 seconds One miss Recommended heartbeat in the 300–3600 range; check every 20–30 minutes

1200–1800 seconds is the recommended heartbeat within the 300–3600 range; 300 seconds is avoided separately.

This passage deserves to be pulled out on its own, because it exposes a dimension most Agent frameworks don't handle: the scheduler not only needs to know when a task should run, but also how much one model-side wake-up costs.

The 5-minute cache TTL splits waiting into two cost intervals; the heartbeat uses 1200–1800 seconds, and longer waits (up to 3600 seconds) are left for fallback.

This is also why the tool description keeps emphasizing “don't poll”:

Do NOT schedule a short-interval wakeup to poll for background work you started — when harness-tracked work finishes, you are re-invoked automatically, so polling is wasted.

Polling in an ordinary backend just wastes requests; in an Agent, every poll is a full model call that rereads the entire context. The first mission of async tools is to take “waiting” out of model calls.

The easiest pitfall: the notification may not arrive

The most fragile part of an async system is the notification chain. 2.1.299 fixed ScheduleWakeup not triggering in the SDK, IDE, remote-control, and while background tasks were running; 2.1.290 fixed background agents reporting “Agent stalled” after Mac sleep/wake, and Workflow subagents restarting from scratch; 2.1.300 fixed recovered agents being judged lost after cloud container replacement.

Conclusion: long tasks need a fallback. ScheduleWakeup's own description says not to poll for work the harness can track, but to keep a fallback above 1200 seconds in case a notification is lost.

Two other easily overlooked boundaries were mentioned earlier; one sentence each: background tasks don't last forever (unattended default 30 minutes, maximum 2 hours; exiting a local session also cleans them up); background cannot consent for the user (permission prompts return to the main session, and cross-session permission laundering is explicitly forbidden).

How to choose: synchronous by default, split by the “nature of waiting”

Need Choice Basis
Returns within seconds Synchronous tool Async task management costs more than waiting
One command takes minutes Bash + run_in_background No reasoning needed, only time
A side task that needs to read files and make judgments Agent (background by default) The result matters; the process shouldn't pollute the main context
Continuous events (logs, CI, WebSocket) Monitor Waiting for change, not for the end
Fixed time / periodic trigger CronCreate; use ScheduleWakeup for self-paced rhythm The trigger condition is time, not task completion
Dozens of agents needing cross-validation Workflow The orchestration itself needs to be solidified, rerun, and resumed
Multiple sessions need to discuss with each other Agent Teams (experimental) Peer collaboration, not mere concurrency

Rows 2–6 in the table are the 5 async forms; synchronous tools and Agent Teams are comparisons. Agent Teams is session collaboration, not single-task async, so it isn't counted among the 5.

Three scenarios that are easy to get wrong:

The common thread in these three errors is treating “concurrency” as a free resource. In reality, every layer of async costs: task management, cache misses, and the subagent's independent context. Choose the wrong form, and the waiting time you saved will eventually be paid back from the token bill.

Common commands, copy them directly:

What you want to see Use Where
Background tasks in the current session /tasks In-session slash command
Dynamic workflow progress /workflows In-session slash command
All background sessions claude agents Terminal CLI
Move the current Bash to background Ctrl+B In-session shortcut
Stop all background subagents Ctrl+X Ctrl+K (confirm twice) In-session shortcut

Ctrl+X Ctrl+K stops background subagents; to stop a specific background Bash, use TaskStop and cancel by task ID.

If you write your own Harness: turn the design principles into a protocol

If you don't write your own Harness, you can skip this section; the selection table above is enough.

These principles, when put into code, are mainly four things:

  1. The protocol must support handles. Synchronous calls return result; async calls return {task_id, output_path, deadline}. Don't make long tasks masquerade as synchronous calls.
  2. Every task needs an ID, an output destination, a cancellation entry, and a deadline. If any one is missing, the task becomes an orphan at some boundary.
  3. Write the model-side cache window into the scheduling strategy. Waiting is not free; if one wake-up rereads the entire context, the scheduling granularity should be designed around the cache TTL.
  4. Permissions cannot be outsourced to the background. Background tasks cannot consent for the user, and cannot hand a rejected operation to another session.

As for “completion events must be injected by the runtime” and “output goes to disk, context only gets a summary,” they are hard constraints at the protocol layer, not implementation details. Without them, the four things above cannot hold.

Finally, something that may offend

The real change in Claude Code's async tools is not “you can run commands in the background” — shells have had & for ages. The change is in the semantics of tool calls: from “a function returns a result” to “submit a task, wait for an event, cancel by ID.”

Once that semantics holds, the bottleneck of an Agent shifts from “how fast is the model” to “how reliable is the scheduler.” Whether background Bash cleans up when the session exits, whether subagent results get lost, whether scheduled tasks still remember after the machine sleeps, whether one wake-up rereads the entire context — these are the watershed of async systems.

So my judgment is: most teams don't need to start with Workflow. Get background Bash, TaskStop, output-to-disk, and deadlines right first; that already captures the most practical layer of async tools. Multi-agent orchestration is the next step, not the first.

A good Harness doesn't eliminate waiting; it turns waiting into schedulable events.

Appendix: the default value change in 2.1.304

This section is the follow-up to the packet capture at the beginning; it doesn't affect the main 5 forms. Skip it if you don't care about version details.

Back to the request body from the beginning. The default tools array in 2.1.304 has 12 tools, two of which are not in the 2.1.303 default: ToolSearch and DeferredToolPlaceholder. But these two are not new tools added in 2.1.304 — after 2.1.303 set ENABLE_TOOL_SEARCH=true, its tool list was exactly the same as the 2.1.304 default. Only the default changed.

The numbers make it clear: of the 20 default tools in 2.1.303, 10 are resident and 10 are deferred; 2.1.304 defers those 10 and adds the two new tools, making exactly 12. In the deferred list, CronCreate, SendMessage, and TaskStop are themselves the control plane of async tasks — in these packet captures with a custom base URL, they also have to go through ToolSearch before they can be called. The full data for the four comparison groups is in the “Data notes” section below.

Conclusion: 2.1.304 didn't change the mechanism; it just turned on tool search by default under a custom ANTHROPIC_BASE_URL. Users who previously went through a gateway had to set ENABLE_TOOL_SEARCH manually; now they don't. The two cross-comparison groups produced identical results (see “Data notes”).

This default switch also brought out a regression: if Vertex (Agent Platform), Bedrock, or a gateway rejects tool search, 2.1.304 makes every request under that configuration fail; 2.1.305 changed it to “automatically drop tool search and retry.” If you use a gateway, 2.1.304 is not a version to stay on.

But no matter how the default changes, ToolSearch is not “background execution”: it still happens within the current turn. The model initiates a search, gets the schema, and continues the call. What it saves is context, not wall-clock time.

Data notes

The 2.1.303 / 2.1.304 request body data in this article comes from local packet capture: download the corresponding darwin-arm64 native package, use -p, a fake API key, an empty config directory, and run a local fake /v1/messages service to record requests; the model is uniformly claude-sonnet-5-5. Tool counts and character counts are measured values from four runs, not token estimates.

Run tools count schema chars entire request body
2.1.303 default (custom base URL) 20 48,860 64,980
2.1.304 default (custom base URL) 12 28,567 45,065
2.1.303 + ENABLE_TOOL_SEARCH=true 12 28,567 45,068
2.1.304 + ENABLE_TOOL_SEARCH=false 20 48,860 64,979

The 1–3 byte differences in the entire request body come from version / config path; the tools JSON itself is identical.

The 10 deferred tools are: CronCreate, CronDelete, CronList, EnterWorktree, ExitWorktree, NotebookEdit, SendMessage, TaskStop, WebFetch, WebSearch.

References