Async Tool Calls: Job Handles for AI Agent Work That Outlives the Turn
Learn how to give AI agents tools that outlive the turn: a submit/status/consume triad with unguessable job-handle capabilities, poll contracts the model actually obeys, webhook callbacks that resume a durable run, idempotent submission keys that make retries free, leases and TTLs that collect abandoned jobs, and a fake-clock CI harness that proves a nine-minute migration never blocks a ten-second turn.
An agent is asked to backfill two million customer rows — deduplicate them, revalidate addresses, write an audit record per change. It has the tool for this: run_migration(scope). The call is well-formed, authorized, and takes nine minutes. Watch the two ways this goes wrong.
In the first, the request-response loop holds. The turn stalls on the pending call for nine minutes: the user watches a spinner, the model holds an open request it can do nothing with, every other capability of the agent sits idle, and any proxy idle-timeout or provider request cap between you and the model can kill the whole run mid-wait. In the second, the timeout discipline from earlier in this series does its job — the call is cut at sixty seconds — but the migration is still running server-side. The model, told the call failed, does the reasonable thing and retries. Two migrations are now racing through the same table.
Both horns come from the same assumption: that a tool call must resolve within the beat of a conversation. Duration is not a failure mode. A nine-minute migration is not a slow tool; it is fast infrastructure doing exactly what was asked. You cannot timeout your way out of legitimate work — you can only stop modeling it as one call.
One operation, three tools
The fix is to split the operation along the seam where the turn ends:
# Submission: validates, authorizes, enqueues, returns in milliseconds
submit_migration(scope: str, idempotency_key: str) -> {
"job_id": "job_9f2c41",
"status": "running",
"poll_after_s": 30,
"status_tool": "migration_status",
}
# Status: cheap, read-only, no side effects
migration_status(job_id: str) -> {
"status": "running", "progress": 0.42, "eta_s": 260,
}
# Result: the only path to the payload
migration_result(job_id: str) -> {
"status": "done", "rows_migrated": 2_043_118,
"duration_s": 541, "audit_log": "migrations/2026-11-audit.jsonl",
}
The submission returns immediately, the turn ends, and the model can do the most useful thing an agent can do with a long job: tell the truth about it — "backfill started, roughly four minutes left" — and go work on something else, or hand control back to the user. The timeout rules from the earlier chapter now bite on the right boundary: submit, status, and result must each answer in well under a second, and a status call that hangs gets exactly the timeout-and-circuit-breaker treatment any other call would.
Three manifest details make or break the triad in practice. The submit tool is the one the model reaches for, so its description must say what it returns and what to do next — the result itself names the status tool, because the model should not have to guess the choreography. The status tool must be obviously side-effect-free so the model can call it freely without your dispatcher's approval gates firing. And the result tool should return a shaped, bounded summary — counts, durations, artifact references — not the two million rows, for the same context-discipline reasons as the tool-response design chapter.
The handle is a capability
A job ID is not a foreign key; it is an access token. Anyone who can name a job can poll it and read its result, and results are first-party data — export manifests, migration audit logs, rendered artifacts. Treat the handle with the same seriousness the trust-boundary chapter treats fetched content:
- Unguessable by construction. 128 random bits, never a sequence. Enumerating
job_1,job_2must not be a valid way to browse other sessions' work. - Scoped to the submitter. The job record carries the session and principal that created it; status and result check that binding, not just the ID's existence.
- Leased, not eternal. A handle carries a TTL, extended by activity, after which the result is garbage-collected. A handle from last week's conversation is not a durable entitlement.
- Minimal by design. One handle reads status and result. Cancellation is a separate capability granted to its own tool, because "can check on the job" and "can kill the job" are different trusts.
Polling etiquette is a contract, not a hope
Left to its own devices, a model that knows a job is running will poll it the way a developer presses F5: immediately, repeatedly, every turn. That burns tokens, hammers the backend, and produces a transcript of forty identical "status": "running" results — which is its own kind of context pollution.
Put the contract in the result instead of the prompt. poll_after_s in the submit response, monotone progress, an eta_s you actually stand behind — the model reliably obeys numbers you hand it and reliably invents strategies when you don't. Reinforce it at the dispatcher with a per-job minimum interval that returns the last known status instead of hitting the backend, so even a hallucinated hot-poll costs nothing. Two properties matter in the status payload: progress must be monotone (a progress bar that jumps backward teaches the model your numbers are noise), and an ETA you cannot support is worse than none — the model will quote it to the user.
When the agent goes home: leases, TTLs, abandonment
The conversation that submitted the job has no obligation to outlive it. The user closes the tab; the run hits its budget ceiling from the resource-budgets chapter; the session simply ends. The job's lifecycle must be independent of the conversation's — which means you decide, explicitly, what abandonment means:
- Effectful jobs run to completion. A migration canceled silently at 70% is worse than one that finished unclaimed — half-migrated data is an incident, a stored result nobody picked up is a log entry. Prefer finish-then-expire: the result is retained until the TTL, and the command ledger records the outcome either way, so the postmortem query "what ever happened to job_9f2c41" has an answer.
- Derivable jobs can be canceled. If recomputing is cheaper than retaining (a re-render, a re-scan), a lease with heartbeat lets the infrastructure reclaim work nobody is watching.
- Abandonment is a state, not a bug. The status tool should say
done (unclaimed)rather than pretend the caller vanished.
Cancellation deserves its own warning: for effectful work, cancellation must be cooperative — checkpoints, transactional boundaries — because a hard kill mid-write is precisely the corruption the migration was designed to avoid. If you expose cancel_job, it schedules a stop at the next safe point and says so.
Callbacks: resume the run, don't occupy it
Polling is the fallback; resumption is the better default when you have durable runs. The durable-agentic-workflows pattern applies directly: the submit call records the job and returns; when the job completes, its completion event starts or resumes the run, injecting the result envelope as the next input. The model never waits — the orchestrator does the waiting, which is the entire point.
The plumbing that makes this safe is boring and mandatory: a correlation ID (run or conversation) travels with the job so the callback knows what to wake; the webhook or internal event carries a signature the receiver verifies, because a resume-a-run endpoint fed by external input is an injection surface like any other; and the resumed turn receives the same shaped, bounded summary the result tool returns — not the raw payload — so the new context starts lean. The model re-derives what it needs from the summary plus whatever session state your harness restores, exactly as it would after any other suspension.
Idempotent submission makes retries free
Splitting the operation moves the retry question from "did it run?" to "did I enqueue it twice?" — and that one has a clean answer. The submit tool takes a client-supplied idempotency key, exactly as the idempotency-keys chapter prescribes for effectful calls: same key, same scope, return the same job_id. A retried turn, a replayed transcript, a model that re-issues the submission "to be safe" — all of them collapse onto one job. Without this, every timeout on the submit path is Schrödinger's enqueue, and the retry that follows is a duplicate you will discover from the row count.
What async does not fix
- Slow because broken still looks slow. A submit call that hangs because the queue is wedged is a failure your per-call timeouts and circuit breakers must catch. Async moves the long wait out of the turn; it does not exempt the short calls from the reliability chapter.
- Human-pace waits are a different shape. A job blocked on an approval gate is not waiting on compute; its handle is a conversation with a person. Modeling approvals as long-running jobs hides the real state and breaks the audit trail — gate them explicitly.
- Streaming partials into context is usually the wrong fix. Feeding the model live progress lines trades one context problem for another: half-data invites premature action, and the transcript pays for every tick. The status tool exists so the model can look when it needs to, not so the harness can narrate continuously.
Testing with a fake clock
None of this is real until a test proves the turn ended. The replayable-harness pattern extends naturally with a fake job store:
def test_long_job_never_blocks_the_turn(fake_jobs, recorded_agent):
run = recorded_agent.run("Backfill the tenants table")
assert run.wall_time_s < 10 # the turn returned fast
assert run.pending_tool_calls == [] # nothing left hanging open
job = fake_jobs.only_job()
assert job.key == "tenants-backfill-v1" # idempotent submission held
fake_jobs.advance(job.id, progress=0.42)
status = recorded_agent.call("migration_status", job_id=job.id)
assert status["eta_s"] > 0 and job.polls == 1 # contract respected
fake_jobs.finish(job.id, rows=2_043_118)
fake_jobs.expire_abandoned(job.id) # nobody ever polled again
assert job.cancelled is False # ran to completion
assert ledger(job.id).outcome == "done (unclaimed)"
The fake store makes duration deterministic: advance moves a job's clock instead of sleeping yours, finish and expire_abandoned drive terminal states, and the assertions land on the properties that define the pattern — the turn never blocks, the poll contract holds, abandonment lands in the ledger. Run the same scenarios against a real queue in one integration test; keep the rest fast.
The turn is not the work
The rules compress to four. Any operation that can outlive the turn becomes submit, status, consume — three small calls instead of one unbounded one. Handles are unguessable, leased capabilities, not IDs. The polling contract rides in the result, enforced again at the dispatcher. And effectful jobs run to completion and get ledgered even when nobody comes back. The synchronous tool call was the special case all along — the fast one. Design for the general case, and the fast one stays fast.
About this story
This article was written by Gen-AI using GPT 5.5 or Opus 4.7. Verify technical guidance before using it in production systems.