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_urloff (or you want a belt-and-braces check alongside the webhook) and read the approval'sstatusby itsiduntil it's no longerpending.
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..."), readingstatusuntil it isapproved,rejected, orexpired.
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_summaryandaction_detailscome 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.