Human approvals
One human decision, bound to one exact payload, spendable once.
On this page
An approval that is not bound to a payload is a rubber stamp
You added a human gate for exactly this reason: somebody should look before an agent moves money. The failure mode is not that nobody approved — it is that the thing approved and the thing that ran were different things. An approval that authorises a refund, rather than this refund of £240 on this order to this account, is a standing licence for every refund that agent proposes afterwards — and the agent proposing them is the component most likely to have been talked into it by a paragraph of retrieved text. The same holds for an approval that can be replayed: if the record survives its first use, one human decision authorises an unbounded number of executions of the action it was granted for, which is the shape of an incident rather than a control.
The second failure is quieter, and it is the one that makes your team switch the gate off. An agent waiting on a human has no callback to wait for, so it re-submits; a gateway that mints a fresh approval per re-submission buries your reviewer under duplicates of the single thing they are being asked to decide, and an approval nobody can find is an approval nobody spends. The queue then fills with rows that are individually valid and collectively unusable, and the honest response to that is to stop gating anything, which is where most human-in-the-loop features end up.
The third is money. A request parked on a human has been priced but not spent, and both obvious treatments are wrong: forget the estimate and an agent can queue a thousand expensive calls past its ceiling while somebody deliberates, or hold it forever and a denied or abandoned approval counts against that agent’s budget until someone edits the database by hand. Token Observe reserves the estimate for exactly as long as the decision is outstanding, and the transfer of that reservation onto the retry is a state machine rather than a convention.
How it actually works
Step 7 of the request path. The order is load-bearing rather than incidental: it is encoded in the evaluator, and the reason each step sits where it does is the reason the guarantee holds.
- 01
A policy asks for a human
Applicable policies are evaluated in ascending priority order and the first require_approval match is held. A block or suspend match anywhere in the same pass wins outright, so Token Observe never asks a person to approve something a different rule already refuses. The on-behalf-of intersection is ordered for the same reason: where on-behalf-of enforcement is switched on — it can be switched off entirely, and then it reads nothing at all — it runs after the verdict and before the approval branch, precisely so nobody is asked to approve an action the named human’s own permissions forbid. A require_approval policy running in shadow mode parks nothing and asks nobody — it records that it would have. - 02
The action is hashed
The governed action and its execution context are assembled into one canonical object and hashed with SHA-256. The action half is the sanitised model request, or the tool name and its arguments, or — under an Effect Contract — the contract id, version, digest, idempotency key hash and action-arguments digest. The context half is the subject, its team and tags, its effective role grants, the ordered delegation chain with each hop’s grants, the on-behalf-of identity, the caller’s session id and tags, the tier hint, and allowlisted headers that change provider semantics. - 03
The trace is parked and the estimate reserved
Creating the approval record and moving its trace from running to awaiting-approval happen in one transaction, guarded on the trace still being a running trace belonging to that agent. For a model request the conservatively priced estimate stays reserved against the agent’s hour, day and month windows while the trace is parked, so the queue cannot be used as a way around a budget ceiling. - 04
The agent is refused with a resumption contract
The agent receives a 403 in its own dialect’s error envelope carrying the trace id, the typed code, the approval id, a retryable flag, a machine-readable status URL and a resume object stating that the retry must match the payload and that the approval is single-use. On the tool gateway the same facts arrive as a tool result flagged as an error, with the code, retryable flag and approval id in machine-readable metadata beside the prose the model reads. - 05
A human decides, with a reason
Deciding needs the operator role or above and an evidence scope that covers the trace’s team; a reason of at least five characters is mandatory and both buttons stay disabled without one. The write is a compare-and-set on the pending state, so when two people decide at once only one is recorded as the decider and the other is told the current state rather than being silently merged into the ledger. - 06
The agent retries the identical request
The retry is hashed again and compared. On the model gateway it arrives as a fresh trace, so consuming the approval and releasing the original parked trace’s reservation are one transaction, and the atomic budget admission excludes that original trace while writing the new reservation — the same action is never counted twice and a crash cannot strand a consumed hold. On the tool gateway the retry continues the parked trace itself, so there is nothing to transfer and consumption is the guarded update on its own. Either way a second retry loses the compare-and-set and is refused as already consumed, and a still-pending re-poll takes no second reservation at all. - 07
Anything undecided expires
Expiry is applied lazily before the queue is listed, before a decision is written and before a presented approval is resolved, so a pending list never shows an approval whose time has already passed. Expiring the row and releasing what it held are one transaction: a best-effort release that failed once would never be selected again, because the row is already expired.
What an approval is bound to
An approval is bound to the SHA-256 of one canonical JSON object containing the governed action and the execution context it was proposed in, and to nothing else. Canonicalisation is what makes the comparison meaningful across two separate HTTP attempts: role grants are emitted with their permissions sorted and their action lists deduplicated and sorted, teams and on-behalf-of identities are lower-cased, tag sets are sorted, and header names are trimmed and lower-cased, so a retry that differs only in map ordering still hashes identically while a retry that differs in substance does not.
The context half is in the hash because changing any of it after a human reviewed the action changes what was approved. An approval granted to an agent holding one set of roles must not still be redeemable after that agent is granted more; an approval granted for a call made on behalf of one person must not be redeemable for the same call made on behalf of another; an approval granted at the end of a two-hop delegation chain must not be redeemable when the chain is different. Each of those is a fact about who is acting, and each of them is in the object that is hashed.
What is deliberately excluded is as load-bearing. Request ids, trace ids, credentials and generated transport session ids never enter the hash, because they change between the attempt that raised the approval and the attempt that redeems it. The proposed-tool-call path once salted the hash with the trace id, and because a trace id is minted fresh per request, every approval raised there was unredeemable: the operator approved, the agent retried, the hash no longer matched the stored one, and the guard raised a second approval — reproduced as a third on a third attempt. It failed closed, so nothing unsafe ever ran, but the documented feature had never once worked and the queue filled with rows nobody could spend.
Because the binding is a property of the action rather than of the attempt, the same envelope is computed by the model gateway and by the tool gateway — on the way in, and again on the retry. The offline seat hook is the one place it is not computed: that module is deliberately pure and holds no hasher, so it compares a hash the caller supplied against the frozen copy of the approval inside the signed bundle. An empty or wrong hash can never equal a granted approval’s, so a caller that omits it fails closed rather than open. An approval is a statement that this subject, with these grants, through this chain, may make this call with these arguments — once.
- The action
- The sanitised model request, or the tool name and its arguments. Where an Effect Contract governs the call, the contract id, its version, its digest, the idempotency key hash and the action-arguments digest are bound too, so an approval cannot survive the contract changing underneath it.
- The subject
- Id, kind (agent or seat), team, tags and effective role grants, normalised so that ordering cannot change the hash. A permission added to a role after the human decided produces a different hash and therefore a mismatch.
- The chain and the human
- The ordered delegation identities with each hop’s grants, plus the on-behalf-of identity, the caller-supplied session id and tags, and the tier hint — everything that alters routing, authority or attribution.
- Excluded on purpose
- Request ids, trace ids, credentials and generated transport session ids. A reconnect must not invalidate a reviewed action, and a fresh trace per attempt must not make an approval unredeemable.
{
version: 2,
subject: { id, kind: agent | seat, team, tags[], roles[] },
delegation: [ { subjectId, roles[] } ], // ordered: origin -> caller
onBehalfOf, sessionId, requestTags[], taskTierHint,
semanticHeaders: { … }, // allowlisted, lower-cased
action: { kind: tool_call, tool, args, effect }
| { kind: model_request, request }
}
canonicalJson(object) -> SHA-256 -> payloadHashRequest, decision, denial, expiry
An approval has four states — pending, approved, denied, expired — and one orthogonal fact, the timestamp recording that it has been spent. The states are what a human sets or time sets; the timestamp is what execution sets, and the two are separate because an approved approval that has not yet been redeemed is a real and important state: the human has decided, the action has not happened, and the money is still about to be spent.
Denial is terminal and says so to the agent: the refusal is typed as denied and marked not retryable, and the same transaction that records the decision closes the held trace and clears its estimate. Expiry is terminal too, and it is terminal after approval as well as before it. That was not always true — expiry was checked only while an approval was still pending, so the time limit stopped applying the moment a human clicked approve, which is exactly when it starts to matter. An approval granted at 09:00 with a 60-minute lifetime must not still authorise the action at 17:00, so an approval past its expiry now reads as expired whatever the decision was, until it is consumed.
An expired approval is not the same refusal as a denied one. A denial ends the matter; an expiry returns the verdict to require-approval and a fresh request is raised, because nobody said no — nobody said anything. The default lifetime is 60 minutes, the policy may set any value from one minute to seven days, and a re-submission while a decision is outstanding reuses the pending approval for that exact payload rather than minting a second, because a queue that grows one row per poll is unusable.
A rule can be rehearsed before it gates anything. Every policy can run in shadow mode first, and a require_approval policy in shadow mode raises no approval, parks no trace and pauses no agent — it records that it would have, so the volume of interruptions is a number you have measured rather than a surprise your on-call rota discovers. Where an install has switched the promotion gate on, a rule cannot start enforcing until a backtest against recorded traffic has been acknowledged; that gate is off by default, because imposing a process control mid-upgrade blocks changes already in flight.
- pending
- The trace stays parked and its estimate reserved. Re-submitting the same payload returns the same approval id rather than creating another, and takes no second reservation.
- approved
- A permission, not an execution. The reservation is deliberately kept, because the action has not happened yet; the approval’s own expiry is what releases it if the agent never comes back.
- denied
- Closes the held trace as blocked and clears the estimate in the same transaction as the decision. A retry is refused as denied and flagged not retryable.
- expired
- Swept lazily whenever the queue is read or a decision is written, releasing the hold. The agent is told a new approval is required rather than that it was refused.
{
"name": "Refunds over £200 need a human",
"mode": "enforce",
"priority": 10,
"scope": { "teams": ["customer-support"] },
"trigger": {
"kind": "tool_call",
"toolPattern": "orderdb.issue_refund",
"argMatchers": [{ "path": "amount", "op": "gt", "value": 200 }]
},
"action": { "type": "require_approval", "approvalTtlMinutes": 30 }
}What the agent sees, and how it resumes
The agent sees a 403 in the error envelope of whichever dialect it speaks — OpenAI, Anthropic or Gemini — carrying the trace id, the typed code, the approval id, a retryable flag, a status URL and a resume object that states that the payload must match and that the approval is single-use. Those last two fields exist so the contract is machine-readable rather than folklore: an SDK integration reads the resume object instead of a paragraph of documentation, and the whole client-side change is one catch block that re-sends the identical request with the approval id.
The status URL is a read-only, agent-authenticated endpoint that returns lifecycle data only: the status, whether it is ready, retryable and terminal, the next action to take, the timestamps, and — once approved and unspent — the retry instruction. It never returns the payload hash or the human-facing action summary, it is served no-store, and a missing approval and an approval belonging to another agent collapse into the same 404 so the endpoint cannot be used as an approval-id oracle across the organisation. The next action is poll while pending, retry once ready, and stop when the matter is closed, which is the whole state machine an agent needs.
On the tool gateway the same contract arrives inside the protocol the model already speaks: a tool result flagged as an error, carrying the code, the retryable flag and the approval id in machine-readable metadata beside prose that names the approval id, its expiry and the instruction to repeat the exact call with that id attached. The refusal for an unknown or ungranted tool carries the same object in its protocol error, so a client can tell a pending approval from a kill switch, a budget stop or a policy block without parsing English.
Presenting an approval issued to another agent is treated as an escalation attempt rather than a typo: it is logged as an error and refused as a mismatch. On the tool gateway that refusal deliberately happens after the trace is opened rather than before, because it is the one refusal on that path with a named actor behind it — a probe or a replay — and it used to be the only refusal invisible to the flight recorder while every other one had already opened a trace.
{
"id": "apr_…",
"status": "approved",
"ready": true,
"retryable": true,
"terminal": false,
"nextAction": "retry",
"consumed": false,
"requestedAt": "…", "expiresAt": "…", "decidedAt": "…",
"retry": { "payloadMustMatch": true, "singleUse": true }
}The money a parked action is still holding
A model request parked on a human keeps its estimated cost reserved against the agent’s hour, day and month windows for as long as the decision is outstanding. The estimate is conservative by construction: it is priced after the route and its whole fallback chain are fixed, taking the highest input and output rate across every candidate the request could land on, so a failover to a dearer provider cannot turn an admitted request into an overspend. Without the hold, an agent could queue an unbounded number of expensive calls past its ceiling while somebody deliberates, which converts the approval queue into a way around the budget.
Each terminal path gives the money back, and each does it in the same transaction as the state change that ended the wait — denial with the decision, expiry with the sweep, consumption with the spend. That symmetry is the fix for a specific, ugly failure: the release path did not exist at all, so a denied or abandoned approval left its trace parked forever, and because the spend window counts a parked trace at the greater of its billed cost and its reservation, that estimate stood against the agent’s budget permanently. What an operator actually met was two surfaces disagreeing by orders of magnitude — the agent’s spend endpoint counting reservations and the spend report counting what was billed — with nothing to say which was right, and the only remedy being to edit the database by hand.
An approved-but-unredeemed approval deliberately keeps its reservation, because the action has not happened yet and the money is still about to be spent; its expiry is what releases it. When the agent comes back with that exact payload, the atomic budget admission reads the agent’s windows excluding both the new trace and the original approval trace, then writes the new reservation — so the estimate is transferred rather than duplicated, and the same action is never counted twice against a ceiling it is only going to cross once.
A re-poll while the decision is still outstanding is the mirror image: it executes nothing, its original parked trace already carries the hold, and taking a second reservation would both double-count the projection and, where that reservation exactly fills a window, cause the poll to block itself. So a pending re-poll takes no reservation and changes no money, and the only budget effect of waiting for a human is the one hold that was taken when the request was first parked.
What the reviewer sees, and what is recorded
The approvals queue defaults to what is awaiting a decision, and each card carries the action summary, the requesting agent, the policy that paused it, when it was requested, when it expires, a live countdown that turns amber at twenty minutes and red at five, the first sixteen characters of the payload fingerprint, and a link to the trace that led there. The queue is the critical read and is treated as such: the display-name lookups for agents and policies are independent requests, so a failed registry call degrades a name to a stable id rather than blanking approvals that still need a human, and decisions are disabled while the queue is showing a stale snapshot so an old pending state cannot be mistaken for a current one.
Deciding requires the operator role or above and an evidence scope covering the trace’s team, and a reason of at least five characters is mandatory — both buttons stay disabled until one is typed. Be precise about where that minimum lives: it is the console that insists on it, while the decision endpoint takes the reason as an optional field of up to 2,000 characters, so an install driving decisions through the API directly should enforce the same rule at whatever is calling it. The decision is written as a compare-and-set on the pending state, which matters more than it looks: without it, two people choosing the same outcome at the same moment would both be told they had decided and both be written into the audit ledger as the decider. The loser is told what the approval is now, and the decision that stands is recorded in the hash-chained audit log with the decider’s identity, the decision, the reason, the agent, the policy, the trace and the action summary, alongside an event published to whatever webhook, Slack, Teams or email receivers the deployment has configured.
The console is explicit that a decision is not an instruction. Approving tells the operator that the agent has to retry for it to take effect, because nothing calls the agent back; it used to say the agent had been told, which was not true of any channel the product has. An approved approval that has not yet been spent is shown as such, in its own pill, because the alternative is a queue reading approved while the work has not happened and an operator reasonably concluding that it did.
One disclosure belongs beside all of this rather than in a data-protection appendix. Where the approval was raised on a tool call the model proposed, the action summary is built from the tool name plus up to 160 characters of that proposal’s arguments, and it is built before egress redaction has run on the response — so the approvals table and the approval webhook can carry that much unredacted model-generated text, and should be treated at the same sensitivity as trace content. Approvals raised on the request path and on the tool gateway carry no arguments at all, only the tool or model name and the policy reason, and the summary is capped at 240 characters in every case.
- Reason, not just a click
- At least five characters, required for both approve and deny, recorded in the audit chain beside the decision and shown on the card afterwards.
- Scoped to a team
- The queue is filtered by the reviewer’s evidence scopes and the decision endpoint re-checks the trace’s team, so the list boundary and the detail boundary cannot drift apart.
- Measured, not felt
- The governance report returns requested, approved, denied, expired and pending counts plus the median time to decision over a window, and flags the result as truncated past a 5,000-approval scan rather than quietly reporting a partial number.
What this does not do
Stated here rather than discovered during an evaluation. Every line below closes off a reasonable assumption a reader would otherwise carry into a proof of concept.
- Token Observe cannot call an agent back. Approving unblocks nothing on its own — the agent has to repeat the request — and an agent that never retries leaves the action undone with an approval nobody spent.
- There is no approver routing. Any user with the operator role whose evidence scope covers the trace’s team can decide any approval in that scope; there is no per-policy approver list, no escalation path, no delegation and no two-person rule.
- The reviewer reads a summary, not the payload. The action summary is capped at 240 characters and the card shows sixteen characters of the payload fingerprint; the full request is on the linked trace, and someone who skims the summary approves what they were shown rather than what will run.
- Offline approvals on a developer seat cannot be single-use. A signed policy bundle records consumption as of the moment it was issued and nothing on the device can change it, so within one bundle’s freshness window a granted approval can be spent twice.
- The gate binds only the calls Token Observe sees. Tool rules are enforced on the tool gateway and on tool calls the model proposes in a governed response; an agent that executes a tool without routing it through Token Observe is a matter for the Shadow-AI radar, not for this control.
If one of those limits is the thing that decides it for you, say so and you will get a straight answer about whether it is on the roadmap or out of scope.
Talk it throughWhat this leans on
Policy engine
One deterministic verdict on every governed request: allow, block, redact, or park it for a human.
Spend controls
Hard USD ceilings, per-minute rate limits and a kill switch, all decided before the request leaves your network.
MCP gateway
One endpoint in front of every upstream tool server, and the same evaluator deciding a tool call that decides a model call.
What exactly is an approval bound to?
To the SHA-256 of one canonical object holding the governed action and its execution context. The action is the sanitised model request, or the tool name and arguments, plus the contract identity where an Effect Contract governs the call. The context is the subject and its team, tags and effective role grants, the ordered delegation chain with each hop’s grants, the on-behalf-of identity, the caller’s session id, tags and tier hint, and allowlisted headers that change provider semantics. Request ids, trace ids, credentials and generated transport session ids are excluded, so a reconnect does not invalidate a reviewed action while a changed argument or a widened permission does.
Does approving in the console make the agent carry on?
No. Token Observe has no channel that reaches an agent; event delivery goes to webhooks, Slack, Teams and email, which are all human channels. An approval is a permission the agent redeems by repeating the identical request with the approval id attached, so for an unmodified coding assistant pointed at Token Observe by base URL, clicking approve moves nothing until the agent tries again. The console says so on the confirmation, and shows an approved-but-unspent approval in its own state, because a queue that reads approved while the work has not happened is how an operator concludes the job is done when it is not.
What happens if the agent changes the request after approval?
It is refused. The retry is canonicalised and hashed again, and a hash that differs from the one stored on the approval is refused as granted for a different action payload, with no execution and no partial credit. That covers changes to the arguments and changes to the context alike: a different on-behalf-of identity, a different delegation chain, or role grants widened between the decision and the retry all produce a different hash. Presenting an approval issued to a different agent is refused separately and logged as an error, because it is an escalation attempt rather than a typo.
What happens if nobody decides in time?
The approval expires and the action does not happen. Expiry is applied lazily before the queue is listed, before any decision is written and before a presented approval is resolved, so a pending list never shows an approval whose time has already passed. Expiring the row and releasing the budget reservation it held are one transaction, so a failed release cannot strand the hold. The agent is then told that a new approval is required rather than that it was refused — nobody said no, nobody said anything — and a fresh request is raised on its next attempt. The default lifetime is 60 minutes; a policy may set one minute to seven days.
Can the same approval be used twice?
No. Consumption is a guarded update that stamps the row only if it is still approved, unspent and unexpired, and the rows it changed are the answer: the loser changes nothing, is told the approval was already consumed, and executes nothing. Where the retry continues the original parked trace, that update is deliberately issued on its own at read-committed isolation, because under a stricter level the loser would raise a serialisation failure instead of reporting zero rows changed, turning a clean typed refusal into an unmapped server error. Where the retry arrives on a new trace, the same guarded update runs inside the transaction that releases the original reservation, so a crash cannot leave a spent approval holding budget.
What does the budget do while a request waits for a human?
It holds the estimate. A parked model request keeps its conservatively priced estimate reserved against the agent’s hour, day and month windows, priced across the resolved route and every fallback in its chain, so the queue cannot be used to get past a ceiling. Denial and expiry release the hold in the same transaction that ends the wait; an approved but unredeemed approval keeps it, because the money is still about to be spent, and its expiry is what gives it back. On the approved retry the reservation is transferred rather than duplicated, and a re-poll while the decision is outstanding takes no second hold.
Prefer to ask a person? Write to us →
Bring us the agent you are least comfortable with.
Write to hello@tenhaw.com with what your agents do, which providers they call and what would have to be true for you to put something in front of them. James Rooney replies. You will get a straight answer about whether Token Observe fits, including when it does not.
no form · no qualification step · no sales desk · the other three ways in