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
ToolSearchdefaults), covered in the appendix; skip it if you don't care about version details.
TL;DR
- Async tools are not “running commands in the background”; they are “the tool returns a task ID and calls you when it's done.”
- Five forms: background Bash, background Agent, Monitor, Cron / ScheduleWakeup, Workflow.
- Most scenarios only need the first two; Workflow is the last step, not the first.
- 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:
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:
- Don't add
&. The tool description is explicit: the command runs detached, survives across turns, and re-invokes the model when the process exits.&is backgrounding at the shell level, which is not the same as the harness's task table. - The meaning of
timeoutchanges. In the foreground it means “how long to wait at most”; in the background it means “how long it may live at most”: default 600 seconds, maximum 7200 seconds. - Output goes to disk. The task return includes an output path; the model uses
Readto get it. Don't dump tens of thousands of log lines back into context.TaskOutputwas already removed in 2.1.277, so stop looking for it.
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:
isolation: "worktree": gives the subagent a temporary git worktree (an independent working directory for the same repository), automatically cleaned up if there are no changes;isolation: "remote": throws it into a remote cloud environment, always in the background, availability decided by the gateway.
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:
- It has a deadline. Default 5 minutes, maximum 30 minutes; in
-psingle-prompt scenarios, maximum 10 minutes. When time is up, it just gives the model a notification and lets it decide whether to start over. - Not available in every environment. Amazon Bedrock, Google Cloud Vertex (Agent Platform), and Microsoft Foundry don't have it; setting
DISABLE_TELEMETRYorCLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFICalso makes it disappear. - It goes through the same Bash permission system. In auto mode, the classifier reviews commands.
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:
- It only triggers when the REPL (interactive session) is idle; it won't interrupt a running turn.
-p/ CI has no REPL, so cron won't trigger there; don't treat it as a CI scheduler. - Each session has at most 50 cron tasks.
- Recurring tasks expire automatically after 7 days; they trigger one last time and are then deleted.
- By default they exist only in memory;
durable: truewrites them to.claude/scheduled_tasks.json. Durable only handles surviving restarts; the 7-day expiry is a rule for recurring tasks themselves and is not extended by durable. - There is jitter: recurring tasks can be delayed by at most half a period (capped at 30 minutes); one-time tasks scheduled on the hour or half-hour can run up to 90 seconds early.
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:
- The user must explicitly opt in. Keyword
ultracode, session-level ultracode, or explicitly saying “use workflow”; the model cannot decide on its own that “this task suits parallelism” and enable it. - The default scale guideline is medium. Fewer than 10 agents; the user can change this in
/config. resumeFromRunIdsupports checkpoint resume. Unchangedagent()calls hit the cache directly; only new or modified ones rerun.
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:
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:
- Using async for short tasks: putting a 10-second command in the background adds the cost of a task ID, notification, output file, and cleanup; better to just wait.
- Parallel work on the same file: subagents can isolate with worktree, but Agent Teams teammates don't do worktree isolation, so work must be divided by file boundaries; otherwise same-file conflicts are hard to avoid.
- Treating Workflow as “more subagents”: Workflow's value is that the script is readable, rerunnable, and resumable, not simply raising concurrency from 5 to 50. Without validation steps and intermediate-result reuse, a plain
Agentfan-out is cheaper.
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:
- 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. - 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.
- 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.
- 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
- Tools reference (Bash / Monitor / TaskStop / Workflow behavior)
- Background Bash commands
- Run prompts on a schedule (
/loop,CronCreate, jitter, 7-day expiry) - Orchestrate subagents at scale with dynamic workflows
- Orchestrate teams of Claude Code sessions
- Create custom subagents
- Scale to many tools with tool search
- Claude Code CHANGELOG