Approvals

An approval pauses an agent at a consequential step and asks a human to sign off before it proceeds — "OK to pay this invoice?", "OK to delete the customer's data?", "OK to email the whole list?". It is the guardrail that every "agent with oversight" architecture needs and few have: a first-class way to block an action on a person's decision, wait for it, and carry that decision back to the agent.

You hand us an approver_email, a short action_summary of what the agent wants to do, and a timeout. We mint a single-use link, email it to that person, and hold the request pending until they Approve or Reject on a hosted page — or the timeout lapses. The agent finds out either by a webhook we fire back or by polling. Nothing about the underlying action is ours to run: an approval is a decision gate, not a task queue. Your agent still does the work; we just hold the door until a human opens it.

How approvals behave

Every request needs three things: who decides (approver_email), what they're deciding (action_summary, plus optional longer action_details), and how long they have (timeout, a required ISO-8601 duration bounded 60s ≤ timeout ≤ 30d). Two fields are optional: a callback_url (present → we push the decision back as a webhook; absent → you poll) and free-form metadata we echo back untouched on the decision.

An approval's status is stored, and there are exactly five values — one live, four terminal:

Status Meaning
pending Awaiting a decision; the only live state.
approved The approver clicked Approve (terminal).
rejected The approver clicked Reject (terminal).
expired The timeout lapsed before anyone decided (terminal).
cancelled The agent withdrew the request (terminal).

The four terminal states never re-open — a second attempt is a brand-new request. A decision and the timeout can land at nearly the same instant; the decision wins the race. Whichever transition commits first settles the row, the other no-ops, and exactly one outcome (and at most one webhook) results.

Approvers

The person who decides is an approver, not a login. There is no sign-up, no password, no session: the capability URL we email them is their entire surface. Approvers live in a lightweight per-Team table keyed on (team, email) — you name one by email on each request and we find-or-create it. The same email reached from two different Teams is two separate approvers; there is no cross-tenant identity to leak.

Request an approval

Every request is idempotent: pass an idempotency key and a retried request returns the original approval instead of emailing a second link. See Idempotency for choosing keys.

There is no "new approval" button. An approval is an agent action — the agent decides it needs sign-off mid-task — so requests are made over the HTTP API or MCP, never hand-authored in the dashboard. The dashboard is where you watch and manage the requests your agents raise (see the next two sections); a person signing off does so on the emailed capability page, not here.

POST /v1/approvals/ with your API key as a Bearer token. approver_email, action_summary, and a timeout are required; action_details, callback_url, and metadata are optional. Set callback_url to receive the decision as a webhook; omit it to poll. Pass an Idempotency-Key header to make retries safe. The response is the pending approval (its id is the poll/lookup handle); invalid input returns 422, and hitting the tier cap returns 429.

curl https://aigears.example.com/v1/approvals/ \
  -H "Authorization: Bearer aigears_live_..." \
  -H "Idempotency-Key: pay-invoice-8842" \
  -H "Content-Type: application/json" \
  -d '{
    "approver_email": "[email protected]",
    "action_summary": "Pay invoice #8842 — $12,400 to Acme Ltd",
    "action_details": "Net-30 invoice, matches PO-5521. Vendor on file.",
    "timeout": "P1D",
    "callback_url": "https://hooks.example.com/aigears",
    "metadata": {"invoice_id": "8842"}
  }'

Ask the agent in plain language; it maps the request onto an approval_request call.

"Get sign-off from finance before paying invoice #8842 — give them a day."

approval_request(approver_email="[email protected]", action_summary="Pay invoice #8842 — $12,400 to Acme Ltd", timeout="P1D", callback_url="https://hooks.example.com/aigears", metadata={"invoice_id": "8842"})

approval_request takes the same fields as the HTTP body — approver_email, action_summary, action_details, timeout (defaulting to P1D over MCP), callback_url, metadata, and an optional idempotency_key. It returns the pending approval; the agent holds onto the id to poll or to match the decision webhook later.

Wait for the decision

There are two ways to learn the outcome, and you choose at request time by setting callback_url or leaving it off:

  • Push — you set a callback_url, and the moment the request settles we fire one webhook to it. This is the low-latency path: no polling, and the agent can be woken by the delivery.
  • Poll — you leave callback_url off (or you want a belt-and-braces check alongside the webhook) and read the approval's status by its id until it's no longer pending.

The decision webhook fires on every terminal transition except cancel — one delivery for each of approval.approved, approval.rejected, and approval.expired (an expiry is "nobody decided in time", which the agent still needs to hear). Cancel is the exception: the agent withdrew the request itself, so there is nothing to tell it. Inside the standard signed envelope, the data identifies the approval and carries your metadata back verbatim:

{
  "event_type": "approval.approved",
  "data": {
    "approval_id": "01J9Z8XKQR...",
    "status": "approved",
    "action_summary": "Pay invoice #8842 — $12,400 to Acme Ltd",
    "approver_email": "[email protected]",
    "decided_at": "2026-07-03T14:05:11Z",
    "decision_comment": "Confirmed against the PO.",
    "metadata": {"invoice_id": "8842"}
  }
}

The metadata you sent on the request comes straight back, so the agent can correlate the decision with whatever it was doing — no need to keep a side table keyed on approval_id. On an approval.expired event there was no decision, so decided_at is null and decision_comment is empty; the shape is otherwise identical. The envelope, the X-AIGears-Signature header, and the retry/dead-letter rules are the same for every primitive and are documented once in Webhooks & signing; for standing up a receiver and verifying the signature, see Receiving webhooks. Delivery is at-least-once, so make your handler idempotent on event_id.

The Approvals list shows one row per request — approver, summary, status, and when it was raised — most recent first, filterable by status. Open a row for the detail page: the full action context the approver saw, the lifecycle timestamps (including first_viewed_at, so you can see "the CFO has had this open for four hours and not decided"), the decision and the IP it came from, and the linked webhook and email delivery history.

GET /v1/approvals/ lists the team's approvals, most recent first; narrow it with ?status=pending. GET /v1/approvals/{id}/ returns a single approval and is the agent's poll surface — read its status until it leaves pending.

curl "https://aigears.example.com/v1/approvals/01J9Z8XKQR.../" \
  -H "Authorization: Bearer aigears_live_..."

approval_status(approval_id=...) fetches one approval — the poll surface an agent loops on. approval_list lists them all, optionally filtered by status.

"Did finance approve that invoice payment yet?"

approval_status(approval_id="01J9Z8XKQR..."), reading status until it is approved, rejected, or expired.

Cancel a request

When the reason for asking goes away — the invoice was voided, the agent changed course — withdraw the request. Cancel is valid only from pending: cancelling a live request moves it to cancelled, and cancelling one that has already settled (approved, rejected, expired, or cancelled) returns a 409 — there is nothing left to withdraw. Cancel is also the one terminal transition that fires no webhook: the agent initiated it and already knows, so there is nothing to deliver.

Open a pending approval (from the list or its detail page) and click Cancel. The control is offered only while the request is pending; once it settles, the row is read-only.

POST /v1/approvals/{id}/cancel/. It returns the cancelled approval, or a 409 if the request had already settled and a 404 if the id is unknown.

curl -X POST https://aigears.example.com/v1/approvals/01J9Z8XKQR.../cancel/ \
  -H "Authorization: Bearer aigears_live_..."

approval_cancel(approval_id=...) withdraws a pending request and returns the cancelled approval; calling it on one that has already settled comes back as a tool error the agent can read.

"Never mind the invoice — it was voided. Cancel that approval request."

approval_cancel(approval_id="01J9Z8XKQR...")

What the approver sees

The approver gets a plain email from us, on behalf of your Team, that names the action (action_summary) and links a hosted page. That link is a capability URL: the token in it is a 32-byte opaque random string that is the credential — unguessable, stored only as a hash, looked up by hash. It is not a signed URL and there is no signature to verify (that scheme is reserved for webhook payloads). It is single-use in the precise sense that it is consumed on decision, not on first view: the approver can open it, read, close it, consult a colleague, and come back — repeat visits keep working until they actually Approve or Reject (optionally leaving a comment, which rides back to the agent as decision_comment). Once decided, the same URL renders read-only ("decided on … — …"), so a revisited link is never a 404 and never re-opens a settled request.

The hosted page is built for a trust-critical action taken by someone who is not a logged-in user, and its whole design is anti-phishing:

  • It lives on our own domain, at a predictable path — the v1 defence against look-alike approval emails. Approvers can learn to trust the origin.
  • Agent-supplied text is quarantined. action_summary and action_details come from the agent, which could be compromised, yet they're shown to a human who trusts our page chrome. So they render as plain text inside a visually distinct, labelled "the agent says" block — never as HTML, never able to spoof our own UI. The approver always sees where our words end and the agent's begin.
  • The buttons work with JavaScript disabled. Approve/Reject is a plain form submit, so the decision goes through on a locked-down or JS-disabled device.

Every decision is recorded — who decided, when, from what IP — and shows up in the approval's detail page and the audit log, so there's a durable record of the sign-off behind whatever the agent did next.

Limits

Approvals are metered as a flow: what counts is the number of requests you make per month, not how many are outstanding at once. Requesting an approval consumes one unit of that monthly allowance; a retry that replays an existing request under the same idempotency key does not, and reads — polling status, listing, viewing the hosted page — are never metered. The decision and the webhook it fires are part of the request you already counted, not separate events. The per-tier caps, and what happens when you reach one, live in Limits & tiers.