Skip to content

Guides

Add Human-in-the-Loop Approvals

Gate sensitive third-party calls behind an explicit approval, polled or awaited from the application.

By the end of this guide, an agent or backend service can issue a third-party call that pauses for explicit human approval before executing. The approver clicks a link, reviews the request, approves or denies, and the application gets the eventual result.

The flow:

  1. An agent or backend issues proxy_request() for a sensitive call.
  2. Alter routes through the approval pipeline and returns a PendingApproval immediately.
  3. The application surfaces each approval-gate link to its designated approver (in-app, by email, in chat).
  4. The approver approves or denies in the Wallet.
  5. The application polls or awaits the outcome and receives the eventual provider response (or an ApprovalDeniedError).
  • An app with an approval-enabled grant or agent. Approvals are configured on the policy attached to the grant or agent.
  • A way to surface the approval URL to the approver — most apps render it as a notification, send a DM, or open a modal.

“Require human approval” is the operator authoring label for the require_approval runtime-policy rule type — the same evaluator, audit rows, and approvals queue whichever surface authors it. In the developer portal there are two ways to turn it on. For every call through one grant, open the grant’s editor and enable the Human-in-the-loop approval block, adding the approver. For only some calls, or for a gate above one grant (agent-, provider-, or app-wide), open that agent’s, provider’s, grant’s, or app’s Runtime policies section, click Add runtime policy, choose the Require human approval type, set the condition, and add the approver. From the CLI the two are alter managed-secrets grants create <secret-id> … --grant-policy @file.json (or grants update <grant-id> --grant-policy @file.json) for the grant block and alter policy rules create --type require_approval for a rule — see Set policies. Each call the gate matches now pauses for approval. A rule’s approver list currently designates its first address; to require several people to approve, create distinct approval rules with distinct approver sets at applicable policy levels. Alter combines those into separate gates and executes only after every gate approves. Each approver’s decision is recorded as its own approval.granted or approval.denied audit event naming that approver, on the same trace as the request it decides.

A managed-secret grant has no default approver, so enabling approval on one requires at least one explicit approver email — a save without one is rejected on every authoring surface (the portal’s grant editor and policy-rule forms, the CLI, and the backend with a structured error) rather than failing later at the grant’s first use. OAuth grants may leave the list empty; approval then defaults to the grant’s resource owner — the end user who connected the account. When an agent calls through a grant that user delegated to it, the approval still goes to that user, never to the agent or to whoever made the call. A connection that no end user connected has no owner to default to: a gated call on it is refused with approval_config_error, and the rule needs an explicit approver. An agent-level approval rule may also leave the list empty for OAuth use, but the save warns that any managed-secret grant the agent serves would be denied at runtime until an explicit approver is added.

A grant has two approval-authoring surfaces that coexist: the grant editor’s Human-in-the-loop approval block (the baseline gate — one unconditional requirement, replaced as a whole each time the grant’s settings are saved, and reflected back when the editor reopens) and policy rules (named, optionally conditional approval rules, added in the Runtime policies section, the wallet, or alter policy rules create --type require_approval). Saving the settings block never touches the rules, and vice versa — turning the block off removes only the baseline gate, and each additional rule remains its own independent gate. The block’s enforcing rule appears in the rules list labelled “managed by the grant’s settings”; edit it through the settings, not the rules editor.

proxy_request is the variant of request that runs through the approval pipeline. If the policy requires approval, the call returns a PendingApproval instead of a provider response:

from alter_sdk import Agent, HttpMethod
from alter_sdk.models import PendingApproval
agent = Agent(api_key=os.environ["AGENT_API_KEY"])
pending = await agent.proxy_request(
HttpMethod.POST,
"https://api.stripe.com/v1/refunds",
grant_id=STRIPE_GRANT_ID,
json={"charge": "ch_abc"},
reason="Customer requested refund per ticket #1234",
)
if isinstance(pending, PendingApproval):
# One gate is typical; N-of-N policies return one link per gate.
for gate in pending.gates:
await notify_approver(gate.approval_url)
import { Agent, HttpMethod, PendingApproval } from "@alter-ai/alter-sdk";
const agent = new Agent({ apiKey: process.env.AGENT_API_KEY! });
const pending = await agent.proxyRequest({
method: HttpMethod.POST,
url: "https://api.stripe.com/v1/refunds",
grantId: STRIPE_GRANT_ID,
json: { charge: "ch_abc" },
reason: "Customer requested refund per ticket #1234",
});
if (pending instanceof PendingApproval) {
for (const gate of pending.gates) {
await notifyApprover(gate.approvalUrl);
}
}

The reason field is recorded in the audit log and shown to the approver. For cataloged provider operations, the Wallet also shows the attested operation name and security-relevant projected values (for example, a payment amount and recipient) before the decision. For a request no operation catalog describes — a custom API, or an endpoint the catalog does not cover — the approver sees what the agent sends: every value of the request, named by where it is sent (body.amount, query.limit, header.stripe-account), exactly as sent, as its JSON literal (the number 1250 and the string "1250" read differently). An approval is consent to exactly what executes, so nothing is hidden on account of its name. Alter’s own credential for the grant is never part of the agent’s request — Alter adds it after approval, and an agent cannot send Authorization or Cookie — so it is never shown. The only values listed by name without their value are the transport sign-in headers Authorization, Proxy-Authorization and Cookie, and the headers or query parameters Alter fills with the grant’s credential. An agent cannot set those: a request that puts a value in one is refused with a 422 and the error credential_slot_in_request, naming the field, and Alter removes every such field from every proxied request (approved or not) before it adds the credential, so it carries Alter’s value or nothing. If the grant’s credential settings change after approval so that Alter would fill a field the approved request sets, the approved request fails instead of running differently from what was approved. A request whose values cannot all be shown faithfully is refused when it is made, with a 422 and the error approval_request_unsupported naming the field and the reason, so no approval is created that nobody could approve: more than 1,000,000 characters of field names and values together (counted on the values themselves; escapes added for display do not count), nesting deeper than 32 levels, or two fields that would be shown under the same name (such as headers differing only in case). Long values and many fields are otherwise shown in full: the Wallet shows every value, in a scrollable block, and never shortens one. A request to a cataloged operation is refused the same way when its parameters cannot be read because of the request itself, such as a body that is not a JSON object, a Content-Type the operation is not read from, or a missing or wrongly typed field. An approval created before this check that cannot be shown is marked not approvable in the Wallet, which names the field and the reason. The query string is shown as Alter sends it to the provider: a ; is part of a value, an invalid percent-escape such as %ZZ is sent, and shown, as its literal text, and a valid escape of a byte that is not UTF-8 (such as %FF) is sent, and shown, as the replacement character �. Each query parameter is shown on its own, a repeated one as the list of its values in the order they are sent. A parameter any of whose values contains + on the wire (read as a space by some servers and as a plus by others) is shown as its exact encoded text, every value in order, under query.<name> (encoded, as sent), with its name also exactly as sent; its values count toward the character bound by the characters they decode to. Each value of a repeated parameter is shown and counts toward the character bound. Invisible and text-direction control characters are shown as visible escapes.

If the frozen request cannot be integrity-checked or its security-relevant values cannot be projected, the Wallet keeps Deny available but blocks Approve. The request must be retried after the context is available. When only a newer Wallet page could show every value of the request (the approver has an older page open), the Wallet says the page is out of date and offers a reload instead; Deny still works without reloading.

Two surfaces for collecting the outcome:

Await inline — block until approved/denied/expired (with a timeout):

from alter_sdk import ApprovalDeniedError, ApprovalExpiredError, ApprovalTimeoutError
try:
result = await agent.await_approval(pending.approval_id, timeout=300)
# result.status_code is the provider response status
# result.body_json() decodes the response body
refund = result.body_json()
except ApprovalDeniedError as e:
log.info("Approver denied", approval_id=e.approval_id)
except ApprovalExpiredError:
log.info("Approval window elapsed before decision")
except ApprovalTimeoutError:
# The SDK gave up waiting; the approval may still be pending
pass
import {
ApprovalDeniedError, ApprovalExpiredError, ApprovalTimeoutError,
} from "@alter-ai/alter-sdk";
try {
const result = await agent.awaitApproval(pending.approvalId, { timeoutMs: 300_000 });
const refund = result.bodyJson();
} catch (e) {
if (e instanceof ApprovalDeniedError) { /* … */ }
else if (e instanceof ApprovalExpiredError) { /* … */ }
else if (e instanceof ApprovalTimeoutError) { /* … */ }
else { throw e; }
}

Poll — for longer-running approvals (up to the configured 24-hour maximum), persist pending.approval_id and poll status from a worker:

status = await agent.get_approval_status(approval_id)
if status.is_terminal:
handle_terminal(status)
const status = await agent.getApprovalStatus(approvalId);
if (status.isTerminal) handleTerminal(status);

Key scopes. The status poll and result fetch accept the same key that made the proxy_request call — a key allowed to initiate the deferred call (proxy:execute) can always follow it to completion, with no additional scope. A monitor-only service that polls approvals without making provider calls uses a key holding approvals:read instead.

For a single-gate policy, PendingApproval.approval_url is the Wallet deep link. For N-of-N approval, PendingApproval.gates contains every gate’s separate approval_url; deliver each link to that gate’s designated approver. The top-level approval_id remains the handle used to poll or await the combined result. Most apps surface approval links as:

  • An in-app banner with Approve / Deny buttons opening the URL.
  • A Slack DM to the configured approver.
  • A push notification with the URL as the action.
  • An email when the approval policy selects the email channel and the Alter deployment has email delivery configured; otherwise the URL is the only delivery.

The URL contains no decision credential. The Wallet requires a valid app-user session and only the designated approver can load or decide the request; another authenticated user receives a not-found response. The approver is matched by email, so the identity provider must assert that email as verified (email_verified: true in the sign-in token); a Wallet user whose email the identity provider has not verified cannot load or decide approvals until they verify it and sign in again. Treat the URL as sensitive context and deliver it only to the intended approver.

proxy_request may return either PendingApproval (policy required approval) or an ApprovalResult (no approval needed; the call ran synchronously). Branch on the return type:

if isinstance(result, PendingApproval):
handle_async(result)
else:
handle_immediate(result) # ApprovalResult — body is the provider response

This means an approval policy can be enabled or disabled per-grant without changing application code; the call site handles both paths.

reason is the single most useful field for the approver. Write a concrete sentence — “Refund $X to customer @ for ticket #Y” — not a generic “agent action.” The approval audit event includes the reason.

ErrorLikely causeFix
ApprovalDeniedErrorThe approver clicked Deny.e.message contains the approver’s reason if provided (otherwise a default message); e.details contains only the approval ID. Avoid logging free-form decision text.
ApprovalExpiredErrorThe approval window elapsed (default 10 minutes, configurable up to 24 hours on the policy).Re-issue the call. Adjust the policy’s window if too short.
ApprovalTimeoutErrorThe SDK’s local wait timed out; the approval may still be pending.Persist approval_id and re-poll.
ApprovalExecutionFailedErrorThe approver approved, but the provider call failed at execution time (grant revoked between approval and execution, provider error, etc.).Inspect e.message for the failure and e.approval_id (Python) / e.approvalId (TypeScript) for correlation.
Status poll returns 403 insufficient_scopeThe polling key holds neither proxy:execute nor approvals:read.Poll with the key that made the proxy_request call, or mint the monitor key with approvals:read. The error body names the accepted scopes.
PendingApproval never resolvesNo approver was notified.Verify the policy’s approver list and the application’s notification path.
The approver’s Wallet approvals page shows “Your email address has not been verified by your identity provider. Verify it, then sign in again.”The identity provider did not assert the approver’s email as verified (email_verified: true) in the sign-in token, so the approver cannot be matched.Have the approver verify the email with the identity provider, then sign in to the Wallet again.

Report an issue with this page

Necessary

Required for sign-in, security, authorization, and remembering your choices.

Always active

Analytics

Helps us understand which product and documentation features are useful.

Performance diagnostics

Uses performance tracing and privacy-masked session replay to diagnose problems.

You can change these choices at any time from Cookie settings.