Skip to main content
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.

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