AI Development

Async Tool Calls: The Provider Stopped Waiting, Your Job Table Doesn't Exist

The API will now hold a call_id open while your tool takes four minutes. It will not run the job, retry it, or tell you it finished. That part was always yours — it was just hidden behind a timeout.

KAKabir AnandLead Developer
10 min read
Editorial photograph of a row of brass key hooks on a worn wooden board in a hotel back office, most hooks empty and three holding tags on cords

One tool on a client's agent takes ninety seconds on a bad day. It reads from a warehouse, waits on a partner API that has never published an SLA, and writes a report. The edge in front of that agent times out at sixty. For most of this year our answer was the answer everyone gives: make it faster, then split it, then cache it, then accept that some share of turns die and retry them. None of that was engineering. It was rationing against a number nobody had chosen for this workload.

On 3 September the Responses API picked up async tool calling, mid-turn steering, and the ability to change reasoning effort inside a conversation. Three controls in one release. It reads like a latency feature and it is not. It is the provider saying the model is no longer the thing holding your request open — which means whatever else was holding it open is now visible, and all of it belongs to you.

We spent last week moving one production agent onto this shape and scoping a second. The prompt changes took an afternoon. Everything else was backend work that had been quietly deferred since the day the agent shipped.

The synchronous loop was a deadline, not a design

Every agent backend written in the last two years is the same while loop. Call the model, read the tool calls, execute them, append the outputs, call the model again, stop when it stops asking. It is a good loop. It is also entirely inside one HTTP request, which means the ceiling on a turn is not a product decision — it is the shortest timeout anywhere on the path.

On that client the binding constraint was a sixty-second edge timeout set three years ago for a REST API that no longer exists. Nobody picked it for the agent. It was simply the first thing to give up, and every design decision above it had bent around it for eight months.

  • Tools shaped by the timeout, not the task.— the ninety-second report became three tools that each fit under sixty, reassembled by the model in the prompt. That is three round trips, three chances to hallucinate a join, and a prompt that has to explain our infrastructure to a language model.
  • Retries that redo work you already paid for.— a turn that dies at fifty-eight seconds has already spent tokens and already run side effects. We measured a month of it: 6% of turns retried, and roughly a fifth of tool spend went on results nobody ever read.
  • Latency budgets that are actually one number.— a fast tool and a slow tool share a single deadline, so the slow one sets the policy for all of them. Raising the edge timeout to fix that just moves the ceiling and keeps the shape.

The registry is the migration

Read the release carefully, because the boundary is the whole story. The API will accept a tool result later, correlated against the original call_id, and will resume the conversation when it arrives. It will not run your job. It will not remember that a job exists. It will not retry it, time it out, or tell you it finished. Async tool calling moves the correlation problem into the protocol and leaves the execution problem exactly where it was.

So the first thing we write is not a prompt and not a tool definition. It is a table.

db/migrations/014_tool_run.sql
1create table tool_run (
2  call_id text primary key,        -- the provider's id. never ours, never regenerated
3  response_id text not null,        -- what we resume against
4  conversation_id text not null,
5  tool text not null,
6  args_hash text not null,
7  status text not null,           -- queued | running | done | failed | abandoned
8  result jsonb,
9  attempts int not null default 0,
10  started_at timestamptz,
11  deadline_at timestamptz not null-- a run that outlives its usefulness must die alone
12  created_at timestamptz not null default now()
13);
14 
15-- same tool, same arguments, same conversation, already in flight = one run
16create unique index on tool_run (conversation_id, tool, args_hash)
17  where status in ('queued', 'running');

Four decisions are load-bearing here. The provider's call_id is the primary key, because it is the only identifier both sides agree on and the only one a late result can be matched against — generate your own and you will spend a week writing a mapping table that exists to undo that choice. The partial unique index is the idempotency story: a model that asks for the same report twice in one conversation gets one run, and the second dispatch is a no-op instead of a second invoice. And deadline_at is set at dispatch, not derived at read time, so a job that has outlived the reason it was started is expired by a sweeper rather than discovered by a support ticket.

Flat vector illustration comparing a single line looping back on itself and stretching thin against the same line handing a small token to a stack of waiting squares
Nothing about the work changed. The only difference is whether the thing that started it has to stay on the line until it finishes.

Dispatch is a routing decision, not a rewrite

The version of this migration that gets abandoned halfway is the one where every tool goes async. Do not do that. Most tools are a database read that returns in 200 milliseconds, and putting those through a job table adds a write, a poll, a second model call and a whole class of failure in exchange for nothing. Of the twenty-three tools on that agent, four crossed the line.

lib/agent/dispatch.ts
1const INLINE_LIMIT_MS = 2_500;  // above this, the turn should not wait
2 
3export async function dispatch(call: ToolCall, ctx: TurnContext) {
4  const budget = P99_MS[call.name] ?? INLINE_LIMIT_MS;
5  if (budget <= INLINE_LIMIT_MS) return runInline(call, ctx);
6 
7  await db.insert(toolRun).values({
8    callId: call.id,
9    responseId: ctx.responseId,
10    conversationId: ctx.conversationId,
11    tool: call.name,
12    argsHash: hash(call.arguments),
13    status: "queued",
14    deadlineAt: ttlFor(call.name, ctx.surface),
15  }).onConflictDoNothing();  // the partial index does the dedupe for us
16 
17  // the turn ends here. the worker delivers later, against call.id
18  return { status: "accepted", callId: call.id };
19}

The budget number should come from p99 over the last thirty days, per tool, not from an average and definitely not from a guess in a design doc. Averages hide exactly the runs this feature exists for. On our four tools the averages were 1.2 to 4 seconds and the p99s were 11, 34, 61 and 88 — and it was the 11-second tool, the one everybody described as fast, that was killing the most turns simply because it ran on every conversation.

Done this way the cheapest useful version of the migration is one table, one worker, and four tool handlers that return an acknowledgement instead of a payload. The other nineteen keep the code they already have.

The late result is where this actually gets hard

Four minutes after the tool was dispatched, it finishes. Now you have a result and a conversation that has moved on without it. In our first week of production traffic, roughly one delivery in nine landed in a conversation whose state had changed since dispatch: the user had asked something else, closed the tab, cancelled, or been answered from cache by a second run that finished first.

lib/agent/deliver.ts
1export async function deliver(run: ToolRun) {
2  const conv = await conversations.load(run.conversationId);
3 
4  // intent beats completion. a cancel after dispatch wins, always
5  if (conv.cancelledAt > run.startedAt) return archive(run, "cancelled");
6  if (run.deadlineAt < now()) return archive(run, "expired");
7 
8  await client.responses.create({
9    previous_response_id: run.responseId,
10    input: [{
11      type: "function_call_output",
12      call_id: run.callId,  // the original id, four minutes later
13      output: serialise(run.result),
14    }],
15  });
16}

Two lines in there are opinions, not plumbing. The first is that a cancellation timestamp beats a completion timestamp — if the user said stop while the tool was running, the finished result is not a bonus, it is noise, and delivering it produces an assistant message about a question the user visibly abandoned. The second is that expiry archives rather than delivers. A stale answer arriving into a live conversation is worse than no answer, because the user has no way to tell it is stale and every downstream metric will count it as a success.

The work does not stop when the request does

An abandoned run still queries the warehouse, still calls the partner API, still bills. Under the old synchronous loop that cost was invisible — the request died and took the evidence with it. Once every run has a row, it is one query, so put spend on runs nobody read on the dashboard the same day you ship the table. Ours was just under 12% of tool cost in week one, and that number is the entire business case for cancellation being real.

Isometric illustration of a parcel travelling along a conveyor toward a counter whose shutter has already closed, with a side chute diverting it into a bin
Every async design eventually needs somewhere for finished work to go when the thing that asked for it is gone. Decide that on day one or the model will decide it for you.

Steering only means something if you can interrupt

Mid-turn steering is the control everyone demos and the one with the deepest backend dependency. The user types "actually, EU rows only" while the report tool is thirty seconds into a ninety-second run. Accepting that message is cheap. Making it true is not, because there is a worker somewhere that has not heard about it and will happily finish the wrong report and hand it back with a perfectly valid call_id.

Our shape is deliberately dull. Cancellation writes a timestamp on the conversation, workers check it between declared stages, and every async tool has to expose its stages. A tool that cannot be interrupted between stages does not get to be async — it gets to be a job with a status the user can watch, which is a different product decision and should be made by a person, not inherited from a retry loop.

This is also the honest limit of the release. Three new controls arrived together and they compose well, but every one of them assumes an application that can name its in-flight work, address it, and stop it. If you cannot do those three things today, async tool calling will not give you interruptibility. It will give you a faster way to accumulate orphans.

The order we do it in

  • 1Measure p99 per tool over thirty days. The list of tools that need this is almost never the list people expect, and it is usually shorter.
  • 2Write the table before touching a prompt.If there is no row per run, there is no cancellation, no idempotency, no expiry and no cost number — and none of those can be retrofitted onto a fire-and-forget worker later.
  • 3Move one tool, on one surface, behind a flag.The slowest one. Let it run a week and read the archive reasons before moving the second.
  • 4Make cancellation real before you advertise steering.A steer that the worker never hears about is worse than no steer, because the user believes it landed.
  • 5Ship the abandoned-run cost metric on day one.It is the only number that tells you whether the async path is paying for itself.

The reframing worth keeping is that none of this is new work created by the release. Long-running tools always needed a registry, an owner, a deadline and a way to be stopped. The synchronous loop was hiding all four behind a timeout that failed loudly enough to look like a policy. The timeout is gone now, and what is left is an ordinary distributed systems problem that our industry has known how to solve since well before any of us were putting language models in front of it.

Keep Reading

More from the blog

Track Record

The Engineering Partner You Can Build On

Reliable software takes an experienced team that owns delivery end to end. Here’s the track record behind ours.

Book a Free Consultation
01

11+

Years Building Custom Software

02

320+

Projects Delivered Across Web, Mobile & AI

03

85%

Repeat Client Rate

04

12+

Countries Served Worldwide

Trusted by startups and enterprises worldwide

Work With Us

Let’s create
with purpose

Share your goals, timeline, and challenges — we’ll respond with clarity and next steps.

Ambitious ideas deserve thoughtful execution. Start the conversation and let’s define what success looks like.

Team

Acetrum

Est. 2015

4.9/5

Trusted by
top brands

Services interested in:

By submitting, I confirm I’ve read and agree with Privacy and Cookie Policies.

Newsletter

Signals worth
paying attention

No recycled headlines — just the patterns we’re seeing across real client work, distilled into one read a month.

A curated digest of practical thinking and real-world brand perspectives monthly.

No spam. Unsubscribe anytime.