How to design agents for work that takes minutes, not seconds
Running an AI agent task that takes minutes: resumable steps, a run handle instead of a held connection, idempotent side effects, budgets and cancellation.
Decouple the run from the connection. Accept the request, write a run record, and return a run id in well under a second, then let the caller poll that id or subscribe to a resumable stream. Split the work into steps that commit their output before the next one starts, make every side effect idempotent, and give the run a wall-clock budget and a cancel path it actually honours.
Where a four-minute run falls apart
The agent works on your laptop. It reads a spec, calls a search tool six times, drafts a migration plan, writes three files and returns after four minutes. In front of a customer it returns nothing at all, and the four minutes of tokens are already spent.
Connections get cut at layers nobody on the feature team owns. An AWS API Gateway REST integration still defaults to 29 seconds, and since 2024 you can raise that ceiling only on Regional and private REST APIs, by requesting a quota increase that may cost you region-level throttle capacity; edge-optimized REST APIs are fixed at 29 seconds and HTTP APIs at 30. An Application Load Balancer idles connections out at 60 seconds by default, and nginx's proxy_read_timeout is 60 seconds unless someone changed it. Above those, a browser abandons a fetch the moment the tab closes, and a mobile client loses the socket when the radio switches networks.
Then the retry arrives. A client that times out at 30 seconds retries, the second request starts a second run, and an agent that files a Jira ticket files two. Streaming tokens keeps the socket warm and hides the problem for a while, which is worse, because the failure then only appears for the customer whose corporate proxy buffers the response.
None of this is a model problem. The model is doing exactly what you asked for over a transport that was designed for a page render.
How do I run an AI agent task that takes several minutes
The seven steps below are in dependency order: the run record has to exist before anything can poll it, and idempotency has to be in place before you allow a resume. Most of this is ordinary distributed-systems work, applied to a process whose steps cost real money and whose duration you cannot predict. If you are still deciding how much of the work the model should choose at all, agent versus workflow is the prior question, and the surrounding architecture patterns sit under agent orchestration platform.
1. Cut the work into steps that commit
A step is any unit whose output you would rather not pay for twice. In practice that means every model call, every tool call that costs money or time, and every write. Draw the boundaries where the cost asymmetry is real: a cache lookup that takes 8 milliseconds does not need its own row, and a 40-second reasoning call that produced a 6,000-token plan certainly does.
Each step writes its output to durable storage keyed by (run_id, step_id) before the next step reads it. On resume, a step with a stored output is skipped rather than rerun. Keep the step id stable and derived from position and name, so redeploying the code between attempts does not renumber the plan and invalidate everything already done.
Steps that fan out need one row each. A research agent that runs eleven source lookups should record eleven rows, so a crash after the ninth costs you two lookups instead of eleven.
2. Give the run durable state and a handle
The run record is the contract. It exists before any work starts, it outlives every process that touches it, and its id is what the caller, your support team and your traces all key on.
{
"runId": "run_01JB8XQ2K3",
"status": "running",
"tenantId": "acme",
"endUserId": "u_8812",
"currentStep": "draft_plan",
"completedSteps": ["load_spec", "search_docs", "rank_sources"],
"attempt": 2,
"deadline": "2026-09-03T14:31:00Z",
"costCents": 41,
"lastEventSeq": 118,
"cancelRequested": false
}
Status is a small closed set: queued, running, awaiting_input, cancelling, succeeded, failed, cancelled and exhausted. Resist adding a free-text status, because every client will parse it. attempt and deadline belong on the record rather than in the worker's memory, since the worker is the thing that disappears.
Accepting the work returns the handle immediately:
HTTP/1.1 202 Accepted
Location: /v1/runs/run_01JB8XQ2K3
Retry-After: 2
Content-Type: application/json
{"runId": "run_01JB8XQ2K3", "status": "queued"}
The public shape of that endpoint, including keys, versioning and error codes, is covered in exposing your agent as an API.
3. Run it asynchronously, and let clients attach and detach
Once the handle exists, the connection carries no state worth protecting. A caller polls GET /v1/runs/{id} and gets the record plus a suggested Retry-After; a caller that wants live output opens a stream against the same id. Both read the same run, so a customer can start on the stream, lose their network and finish by polling.
Make the stream resumable by sequence number. Every event carries a monotonic seq, the client sends the last one it saw on reconnect, and the server replays from there:
curl -N -H "Authorization: Bearer $KEY" \
-H "Last-Event-ID: 118" \
"https://api.example.com/v1/runs/run_01JB8XQ2K3/events"
A stream that cannot replay is a stream that loses whatever happened during the reconnect, which on a four-minute run is usually the part the user cared about. Detaching must never cancel: a closed socket is a client that stopped watching, and only an explicit cancel request stops the work.
4. Make anything with a side effect idempotent
Resume implies re-execution, so any step that can be replayed must be safe to replay. The rule is one key per logical action, derived deterministically:
idempotency_key = sha256(run_id + ":" + step_id + ":" + canonical_json(args))
A key generated with uuid4() at call time is worse than no key, because the retry produces a different one and the downstream service treats it as new work. Pass that key to providers that support it, and for the many that do not, insert a claim row under a unique constraint before the outbound call and record the response against it afterwards. A duplicate attempt then hits the constraint and reads the stored result instead of sending the request again.
Reads are the easy case, and pure model calls can simply be recomputed if the tokens are cheaper than the bookkeeping. Anything that emails, charges, posts, deletes or deploys needs the claim row.
5. Put a budget on each turn and a timeout on each step
Four separate limits do four different jobs, and teams routinely ship only the first. A per-step timeout catches a hung tool, and a per-turn wall-clock budget catches a model loop that keeps calling the same search and never converges.
A per-run cost ceiling catches the expensive version of the same thing. A tool-call count cap catches a tool that returns an empty array the model reads as a transient failure.
| Limit | Typical value | What it stops |
|---|---|---|
| Step timeout | 30 s, 60 s for MCP calls | A tool that never returns |
| Turn wall clock | 5 to 30 minutes | A loop that makes progress too slowly to matter |
| Run cost ceiling | A currency amount | An expensive loop that stays inside the clock |
| Tool calls per turn | 10 to 100 | Repeated calls to a tool returning nothing |
Every limit needs a defined terminal state and a message that names which limit fired. A run that ends at the wall clock with status: failed and no reason produces a support ticket; one that ends exhausted with limit: turn_deadline and the last committed step produces a fix.
6. Report progress from committed work, not from optimism
Progress that comes from token counts or elapsed time is a guess presented as a fact, and users calibrate on it within one run. Emit a progress event when a step commits, carrying the step name, the count completed, and the total if the plan is known.
{ "seq": 119, "type": "step_complete", "step": "rank_sources", "done": 3, "total": 7 }
When the total is unknown because the agent is choosing its own next action, say so and report the phase instead of a fraction. "Reading source 4" is honest and useful. A bar that sits at 90% for two minutes teaches people that your progress reporting means nothing, and they start refreshing, which is how you get duplicate runs.
Show the work in progress too. A partially drafted plan or a list of sources already read gives someone something to judge while they wait, and lets them cancel early when the agent has visibly misread the task.
7. Make cancellation real, and decide what happens when the model gives up
Cancellation is cooperative. Set cancelRequested on the run record, have the worker check it at every step boundary, and move the run to cancelling and then cancelled once the in-flight step settles. Killing the process mid-call leaves the side effect committed upstream and no record of it locally, which is the worst of both.
Two things make cancellation trustworthy: bounding how long a step can hold the run in cancelling, and running the compensations you registered for steps that already committed. If a step cannot be compensated, say that in the final record rather than reporting a clean cancel over a ticket you already filed.
The model giving up is a separate ending and deserves its own status. It happens when the loop hits its turn cap without producing the deliverable, when a tool fails the same way three times, or when the budget runs out mid-plan.
Do not return a 200 with an empty string, which is how a silent failure reaches a customer. Return exhausted, the reason, the last committed step, and the partial artefact, then route it to whatever handles the escalation, which for scheduled work usually means the next run picks up from the same record. Recurring runs have their own failure modes, covered in scheduled AI agents.
Where this gets easier
Building all seven of these on top of a request handler is a few weeks of work you will maintain forever, which is the argument for a runtime that already treats a turn as durable state rather than an open connection. Runtype runs turns durably with a run handle you poll, so a dropped client detaches from the stream without killing the work: a watch lease (10 minutes by default) governs how long a client is attached, while the turn itself runs against a separate wall-clock budget of up to 30 minutes, and execution traces record each step's input, output, tool calls and cost against the same run.
Frequently asked questions
- How long can an agent run before I have to make it asynchronous?
- Shorter than most teams assume. Managed HTTP layers cut connections well before a multi-tool agent finishes, and browsers abandon a fetch when the tab closes, so anything with a realistic worst case over roughly twenty seconds should return a run handle. The cheap rule is to make every agent endpoint asynchronous and offer a synchronous convenience wrapper that waits on the handle for a bounded time.
- Should I use polling or streaming for a long agent run?
- Offer both against the same run id. Polling is what a customer's backend integration wants, because it survives restarts, proxies and retries with no connection state. Streaming is what a person watching a screen wants. Streaming has to be resumable from a sequence number, otherwise a reconnect either replays the whole run or silently drops the events that arrived while the socket was down.
- What happens to a run when the worker process restarts mid-step?
- The run resumes from the last committed step, and the interrupted step runs again. That is only safe if the step's side effects are idempotent, which is why the idempotency key has to be derived from the run id and step id rather than generated fresh on each attempt. Steps with no external effect can simply be recomputed, at the cost of the tokens they burn.