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:
- An agent or backend issues
proxy_request()for a sensitive call. - Alter routes through the approval pipeline and returns a
PendingApprovalimmediately. - The application surfaces each approval-gate link to its designated approver (in-app, by email, in chat).
- The approver approves or denies in the Wallet.
- The application polls or awaits the outcome and receives the eventual provider response (or an
ApprovalDeniedError).
Prerequisites
Section titled “Prerequisites”- 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.
Walkthrough
Section titled “Walkthrough”1. Configure the approval policy
Section titled “1. Configure the approval policy”“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.
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. 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.
2. Issue the request via proxy_request
Section titled “2. Issue the request via proxy_request”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, HttpMethodfrom 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. Credential headers and the full raw payload remain hidden.
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.
3. Wait for the decision
Section titled “3. Wait for the decision”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 passimport { 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.
Patterns
Section titled “Patterns”Surfacing the approval link
Section titled “Surfacing the approval link”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. Treat the URL as sensitive context and deliver it only to the intended approver.
Distinguishing pending from immediate
Section titled “Distinguishing pending from immediate”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 responseThis means an approval policy can be enabled or disabled per-grant without changing application code; the call site handles both paths.
Recording why the approval was requested
Section titled “Recording why the approval was requested”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.
Troubleshooting
Section titled “Troubleshooting”| Error | Likely cause | Fix |
|---|---|---|
ApprovalDeniedError | The 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. |
ApprovalExpiredError | The 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. |
ApprovalTimeoutError | The SDK’s local wait timed out; the approval may still be pending. | Persist approval_id and re-poll. |
ApprovalExecutionFailedError | The 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_scope | The 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 resolves | No approver was notified. | Verify the policy’s approver list and the application’s notification path. |