Skip to main content
Human-in-the-loop, as an API: the agent stops before a risky action, the session reports what it wants to do, a person decides in your product, and one POST resumes the same turn. There is no run state for you to serialise and no process that has to stay alive while you wait. An agent that can restart a service or roll back a deployment should ask first, and the asking should happen where your team already is — your dashboard, your incident channel, your ticket queue. What you build here: the agent stops before a dangerous action, your program sees what it wants to do and why, a person decides, and the agent continues or proposes something else.

An API, not a callback

In an agent SDK, human-in-the-loop is something your process does. The OpenAI Agents SDK, for example, pauses the run with an interruption and leaves the waiting to you: “Use state.to_json() or state.to_string() to store pending work in a database or queue and recreate it later.” The pause is real, but keeping it — storage, a queue, a worker that picks the run back up — is yours. Here the pause is the session’s. It sits in requires_action in the control plane, a webhook says so, and any process holding the right token can answer it later. Two limits worth knowing before you design around it. While a turn is parked the workspace stays up — it is not paused underneath — so the wait counts toward the workspace’s two-hour ceiling in limits. And approvals and questions never time out on their own. For a decision that may take until morning, answer with “queued for review” and start a fresh turn when the person decides; Deadlines covers why.

Before you start

A token and a model, as in the quickstart. You do not need a public HTTPS endpoint — this page uses polling, which runs on a laptop behind a firewall. Webhooks are an optimisation, and they come last.

The whole thing

Replace input() with whatever your product already uses to ask a person. Nothing else about the loop changes.

Three choices worth copying

The tool executes nothing. It records a decision; the agent carries the action out itself once approved. That keeps the approval free of side effects, which is what makes it safe to deliver twice. blast_radius is required. An approval request that does not say what is at stake cannot be judged, and a model will happily omit it if you let it. “If you are refused, propose an alternative” is in the instructions. What an agent does after a refusal is a branch you design, not a default you inherit.

The type you did not expect

required_actions holds three types, and only one of them is yours: All three are answerable through this API. They were not: for a while only function_call was, and this page said so and called it uncomfortable. The advice below about telling the agent not to ask is still useful when nothing in your product can answer a question — but it is now a choice rather than the only way out. An earlier draft of this page used an on-call scenario. Run against a real session, the agent did not call request_approval — it asked a clarifying question, which arrives in the same list as {"type": "question", "name": "…the sandbox is empty, how should I proceed?"}. Adding the log file it said it was missing did not fix it; it asked again. So a poller that assumes every entry is a function call answers a question wrongly and then waits forever for a turn that is still blocked. Two things follow, and the script does both: Branch on type. Each has its own event, and sending the wrong one is a 400 that names the right one:
approved must be true or false — it is not defaulted either way, because denying something you meant to allow is a mistake and the other direction is worse. answer reaches the model as written. If nothing was waiting on that call_id — already answered, or the turn moved on — you get 202 with "outcome": "no_longer_pending" rather than a refusal, because that is what a retry after a dropped connection looks like. Tell the agent not to ask. One line in instructions —
— is what turned the question into work in the run above. If nothing in your product can answer a question, the agent must not be allowed to raise one.

Refusing is not failing

success describes your tool, not the decision. A refusal is your tool working perfectly and returning “no”. success: False means your approval system itself broke — and the agent will try to recover from a fault that did not happen, usually by retrying the call you meant to deny. Include who approved and when. The transcript then holds the audit trail instead of it living only in your logs.

Answering twice

A dropped connection during POST /events leaves you not knowing whether the answer landed. Send it again: the second one comes back
rather than being applied twice. The three outcomes are accepted, already_resolved and not_delivered — the last meaning the call timed out while you were deciding. All three are normal; see input.md.

Swapping polling for webhooks

Once you have a public HTTPS endpoint, stop polling:
The response carries a secret, once. Store it; it is not shown again, and verifying the signature before trusting a body is not optional — webhooks.md has the formula. Read the session when the webhook arrives; do not act on its payload. Deliveries are at-least-once and may arrive out of order. The notification tells you to look; GET /v1/sessions/{session_id} is what is true. The loop above is already written that way, which is why moving to webhooks changes only what wakes it.

When it goes wrong

You have it working when

  • The agent stops before acting, and required_actions names your tool with all three fields filled in
  • A refusal makes it propose something else rather than proceed
  • Sending the same call_id twice reports already_resolved
  • A question in the list does not break your loop

Next