Agents That Wait: Callbacks, Webhooks, and Long-Running Tools

A twenty-minute export breaks the assumption that tools return quickly. Handles, in-loop polling, and true suspension — plus the expiry field that stops suspended runs leaking forever.

Most tools return in milliseconds. Some don't: a data export that takes twenty minutes, a build, a human approval, a partner API that replies by webhook tomorrow. The naive handling — block the loop until it returns — fails as soon as the wait exceeds a request timeout, and it wastes a held-open process either way.

Long-running tools force a design decision the rest of your agent architecture can avoid: what does the run do while it waits?

Three strategies

Block and poll. The tool handler polls internally and returns when done. Simple, and workable for waits measured in seconds. Beyond that it holds a process and a context open doing nothing, and any timeout anywhere in the stack kills it.

Return a handle. The tool starts the work and immediately returns a reference:

start_export(filter) -> {job_id: "exp_8821", status: "running",
                         eta_seconds: 900,
                         check_with: "get_export_status"}

The agent continues with other work and checks later. This keeps the loop alive and is the right default for waits from seconds to a few minutes — but "checks later" means a polling turn, which costs a full model call each time.

Suspend the run. The loop persists its state and stops. When the external event arrives — webhook, approval, completion — the run is rehydrated and continues. This is the only strategy that works for waits measured in hours, and it's the one with real design requirements.

Suspension needs the resumable design

A suspended run must be reconstructible, which means the state that matters lives outside the process:

{ run_id, suspended_at, waiting_for: {type: "webhook", job_id: "exp_8821"},
  resume_token, state: {...ledger, constraints, decisions...},
  expires_at, on_timeout: "escalate" }

Three fields earn their place through experience:

expires_at — because some events never arrive. A run waiting forever is a leak that shows up weeks later as a mysteriously growing table.

on_timeout — a defined behavior when the wait expires. Escalate, proceed with a default, fail cleanly. Unspecified means the run stays suspended indefinitely.

resume_token — because the resume endpoint is reachable by whatever calls the webhook, and it needs to be authenticated and single-use. ⚠️ A resume endpoint that accepts a run ID alone lets anyone advance anyone's run.

Rebuild the context, don't restore it

When a run resumes after an hour or a day, restoring the original message array is usually wrong. It's large, and it's stale in a specific way: the world may have changed while the run slept.

Better to rebuild from durable state — the task, the constraints, the ledger, and the event that woke it:

Task: export Q3 transactions and file them in the compliance folder
Completed: export requested (job exp_8821)
Event: export finished, 412,882 rows, available at ref://exports/8821
Remaining: verify row count, upload, notify the compliance team

Compact, current, and unambiguous about why it's awake. This also avoids a subtle failure of restoring transcripts: the model re-reads its own earlier statements about what it was about to do, which may no longer be appropriate.

Polling without wasting turns

For the middle case — handle returned, work finishes in minutes — polling naively costs one model call per check. Two ways to avoid that:

Poll in the loop, not in the model. When a handle is returned, the loop can wait and poll on its own, invoking the model again only when the status changes. The model spends zero turns waiting.

Give the agent other work. If the run has independent tasks, do them while the job runs, and check the handle when there's nothing left. This requires the agent to know what's independent — which is a good argument for the explicit work ledger anyway.

✅ Design checklist for a long-running tool

  • Returns a handle immediately; never blocks past a few seconds.
  • The handle has a status tool and a documented terminal state.
  • Status distinguishes running, succeeded, failed, and expired.
  • Failure returns a reason the model can act on, not just a status code.
  • The result is fetchable by reference rather than inlined if it's large.
  • Starting is idempotent by key, so a retry doesn't launch a second job.

That last one matters more than it looks: the most common bug in this area is a resumed or retried run starting the same twenty-minute export a second time.

The takeaway

Waiting is a state, not a pause. Return handles rather than blocking, poll in the loop rather than in the model, and for anything longer than a few minutes suspend the run properly — durable state, an expiry, a defined timeout behavior, and an authenticated single-use resume. Then rebuild context from state on wake rather than restoring a transcript written before the world changed.

Keep reading

Similar posts

Matched on shared tags and category — the more bars, the stronger the overlap with what you just read.