Approval Workflow
A developer hits a policy, model, or budget limit and wants a bounded, temporary way past it. This page explains the request → approve → grant → expire/revoke lifecycle that handles that, and when a decision needs more than one approver.
The lifecycle
Every request an admin can approve goes through the same four stages:
- Request — a developer submits a request (from the Builder Portal, the API, or inline in the tool that was blocked), naming what they need and why. AIControls immediately notifies the approver audience.
- Approve or deny — an admin reviews the request in Security → Approvals and decides. A denial ends the lifecycle; an approval moves to the next stage.
- Grant — approval takes effect immediately: a policy exception is granted, a model becomes usable, or a session's budget increases, depending on the request type (see Request types below).
- Expire or revoke — grants that carry an expiry date lapse automatically with no admin action required. An admin can also revoke a still-active grant early; see Revoke a granted exception — there is no dedicated admin UI for this yet, so it's done via the admin API.
Each stage fires a notification (see Notifications) so the requester and approvers stay in sync without polling the UI.
Held calls are a different thing on the same page. Security → Approvals also lists live MCP tool calls and LLM requests that an Approve policy has parked mid-flight — those are not requests a developer submitted, and they have no grant/expire stage; they simply resume or stop. Identical held calls arriving while an approval is still pending are consolidated into that one entry, so one decision releases (or stops) all of them, while anything arriving after the decision needs a new approval. The decided entry leaves the pending queue immediately. An entry is also withdrawn when the last caller on the holding proxy pod disconnects, even if a caller on another pod is still waiting; that remote caller is stopped. See Consolidated approvals and Decide on a held call.
Before sending a released MCP tool call over Streamable HTTP, AIControls checks the upstream session and reconnects if it is stale. It sends the tool at most once. If the tool fails after sending, AIControls returns the error without replaying it; an approval alone does not confirm execution.
Scoping an approval for an elevated MCP tool call
Approving a held MCP tool call — for example, one the mcp-require-approval-elevated-tool built-in policy parked because the tool is Elevated Privileged — offers a choice of how far the resulting grant should reach, alongside the ordinary Approve/Deny decision:
| Scope | Covers | Best for |
|---|---|---|
| This tool, this session (default) | Only this exact tool, for the rest of the developer's current session | Repeated calls to the same elevated tool within one session — approving delete_branch doesn't also clear drop_table |
| This session, any tool under this policy | Every tool the policy matches, for the rest of the current session | A session you're actively watching, where you're comfortable clearing everything the policy would otherwise hold |
| This developer, for a duration | Every matching call from that developer, across every session, until the grant expires | The existing developer-and-duration exception described above — unchanged |
For an outbound HTTP (egress) hold, the "tool" a grant is scoped to is the request host — approving a held call to api.github.com with This tool, this session covers further calls to that host in that session, and nothing else.
Not every held call can carry a standing exception at all. AIControls files the exception request only when the call has a developer identity to attach it to; a pure machine-to-machine call with no session and no delegated user has none. For such a hold the drawer offers only Approve this call and says so — approving releases the call, and the next matching call is held again.
A session-scoped grant only applies when the held call actually carries a session. A call from a client authenticated with a bare API key and no session falls back to This developer, for a duration, which is the only option offered for such a hold — and that grant covers every tool the policy matches, not just the one you approved. It is still time-boxed, but if you want the narrower outcome for a session-less call, deny the standing exception and approve only the call in front of you.
A client calling the approval API directly, rather than using this screen, can omit the scope field entirely. A session-less hold approved that way is scoped to the tool that was held, and to no other tool under the policy. The screen above always sends an explicit scope, so it never takes that path.
Either session-scoped grant ends automatically when the session itself ends, so an ended session never leaves a live exception behind.
Every grant from a held call is time-boxed
Approving a held call never grants a permanent exemption. The resulting grant always carries an expiry, resolved in this order:
- A duration you picked, when you chose the developer scope and set one. Your choice is used as-is, including a duration longer than the workspace default.
- Otherwise, the configured standing-exception duration — workspace-wide, 30 minutes by default, set in Settings → General → Human Review. It is a default that fills the gap when you didn't choose, not a cap on what you can choose.
- The session's own expiry, which shortens either of the above for a session-scoped grant. A grant never outlives the session it was scoped to.
There is no "never expires" option on this path, and no combination of inputs produces one: the duration setting itself rejects zero and negative values, and approving a held call always resolves to a real expiry. (A policy-exception request raised directly by a developer, rather than created by a held call, is a separate flow and is not covered by this guarantee — an approver there sets the expiry themselves.) That includes the case where the held call's session can no longer be resolved — an expired or swept session, or a client with no session at all — which previously produced a grant with no expiry at all. It now falls back to the configured duration.
A self-hosted deployment can set the starting value in its Helm values (hitl.grantDefaultMinutes); a value saved in Settings takes precedence from then on.
Declining the standing grant
The standing grant is offered by default because an agent that just had one elevated call approved will usually make the same call again, and re-holding every one of them helps nobody. It is still a choice: the approval controls carry an Also grant a standing exception checkbox, pre-ticked, and the consequence — which tool, at which scope, until when — is stated before you click.
Untick it to allow the one held call and grant nothing standing. The call proceeds; the linked exception request is recorded as denied; no grant is created; and the next matching call is held again. See Decide on a held call.
Deciding the hold and its exception request separately
An elevated tool call that is held produces two rows you can act on: the held call itself, and the exception request AIControls files alongside it so the hold shows up in the same pending list as a developer-submitted request. Deciding them in opposite directions — denying the exception request, then approving the held call, or the reverse — is a legitimate thing to do, and the held call's decision is the one that wins. The exception request keeps the decision it already carried, and the divergence is recorded in the configuration audit. More generally, the held call is committed first and on its own: nothing that goes wrong while resolving the exception request afterwards — a conflicting prior decision, invalid grant data, a failed write — can undo the call's release. In that case the request is left pending with no grant, and the anomaly is logged.
Approve all pending in this session. When two or more elevated calls in the same session are pending at once — common when several tool calls fire from the same turn — the Approvals list offers a single Approve all pending in this session action instead of deciding each one individually. It grants one session-scoped exception per distinct policy among that session's pending holds and resolves every one of them in the same action; other sessions are never affected. Any hold that's already been resolved elsewhere by the time the action runs is skipped and called out separately, rather than silently double-decided.
See Decide on a held call for the step-by-step.
Requesting inline, without leaving the tool
Stage 1 does not have to happen in the portal. When a policy blocks a developer, the denial itself can carry the offer of an exception, so the request is made from inside the conversation that hit the wall:
- Violation — a policy denies a chat request, a model, or an MCP tool call.
- Offer — instead of a bare error, the developer gets a message naming the policy, explaining what was blocked, and telling them how to ask. On the chat surfaces that is a reply grammar (
request exception: <why>); on the MCP surface it is a machine-readable block plus two governance tools the AI tool can call. Both carry a short-lived, signed offer. - Request — the developer replies (or the tool calls
aigov__request_exception). AIControls files a pending request and answers with its status. The blocked action does not run — a request is never a grant. - Approval — the request lands in Security → Approvals exactly like a portal-submitted one, and an admin decides. The approver's identity is recorded on the decision.
- Grant and expiry — approval creates the same time-boxed grant, and it lapses or is revoked the same way (stages 3 and 4 of the lifecycle above).
Retrying the same blocked action while the request is still open returns its live status — the request id, and once decided, the outcome and who decided it — rather than offering again. That keeps a single request per developer per policy no matter how many times the block re-fires.
See Request an exception inline for the developer-side steps on each surface.
The offer is authenticated; the conversation is not. The offer AIControls attaches to a denial is signed and bound to the policy (or model) that produced it, the identity it was issued to, and a ten-minute window. Redeeming one can only file a request for that exact target, attributed to that identity. Text a developer — or injected content in a document or a web page — writes into the conversation cannot forge one, and cannot change what an existing one asks for. The justification is the only part of the request that comes from the conversation, and it is stored and displayed as data for the approver to read.
Per-developer limits (5 unresolved requests, 20 per hour) bound how much review load any one identity can create.
Single- vs multi-approver co-sign
Most requests need only one approver — whoever reviews it first decides its outcome. Higher-risk requests can require co-sign: two or more specific approvers must all approve before the grant is final. While co-sign is pending, the request shows as Under Review in the Approvals list with a checklist of who has and hasn't decided yet.
The first approver's decision (the one who names the additional required co-signers) creates the grant immediately — it's live and enforced for the rest of the review window, not just once every co-signer has decided. Co-sign is a review-and-veto period on an already-active grant, not a hold before one exists. This is why a later denial (below) revokes rather than simply declining to create.
Co-sign resolves as follows:
- Any approver denies → the request is immediately denied, regardless of how the other approvers were leaning, and the grant created when co-sign started is revoked — access reverts as if it had never been granted.
- Every required approver approves → the request is marked approved; the grant created when co-sign started remains in effect unchanged.
- Some approvers still pending → the request stays Under Review; the grant stays active and nothing else happens until the quorum is reached one way or the other.
Only the approver named in a decision can record it — the request/approve/deny API rejects a decision submitted by anyone else, so approvers can't act on each other's behalf.
Because two co-signers can act within moments of each other, AIControls guarantees the request completes exactly once no matter how close together the deciding decisions land — you'll never see a double approval, a double revoke, or a duplicate notification from a co-sign race.
Request types
| Request type | What approving it does | Durability |
|---|---|---|
| Exception | Grants a time-bounded exception to a named policy for the requesting developer | Expires at the configured time, or until revoked |
| Model access | Grants the requested model to that developer via the same config-target exception mechanism a policy exception uses, on top of their team/org ceiling | No expiry is set by default, so the grant persists until revoked from Security → Approvals → Granted or the admin API — see Revoke a granted exception. (Grants approved before this mechanism shipped are separate, durable per-developer overrides — still revocable via the admin API DELETE /api/v1/developers/{id}/model-overrides.) |
| Session budget top-up | Adds a one-time dollar amount to the requesting developer's current session's spend cap only | Ephemeral — scoped to that one session; see Grant a session budget top-up |
| Extend session | Extends the requesting developer's current session by the requested number of hours | Applies once, to that session |
| Resume session | Reactivates a paused session (e.g. after an anomaly auto-pause) | Applies once |
"Session budget top-up" here is a different mechanism from the durable, org-wide budget top-up described in Set a Budget → Exceptions, which raises a team or user's monthly budget cap via a config-targeted exception rather than a single session's cap. Use this workflow when a developer just needs a bit more headroom to finish what they're doing right now; use the Cost Governance exception flow for a durable, month-over-month adjustment.
Policy exceptions are the request type most often needed at scale — see Policies, Set a Budget, and Restrict Network Egress for the policy-, budget-, and egress-specific exception mechanics each of those pages documents in more depth.
Notifications
| Event | Fires when | Recipient |
|---|---|---|
exception_approval_requested | A request is submitted and needs approver action | Admins |
exception_granted | A grant is created — single-approver decision or the final co-sign approval | The requesting developer |
exception_denied | A request is denied — by a single approver or any co-signer | The requesting developer |
exception_expired | A granted exception's expiry time passes | The requesting developer |
exception_revoked | An admin revokes a still-active grant early | The developer, plus an admin audit copy |
Configure which channels (in-app, email, Slack/Teams/webhook) carry each of these from Settings → Notifications → Routing — see the Generic Webhook event type table for the full list of event types AIControls can deliver.
See also
- Request an exception inline
- Approve or deny a request
- Grant a session budget top-up
- Builder Portal
- Policies
- MCP Governance — Elevated Privileged tools and the built-in policies that hold them