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”In the developer portal: pick the agent or managed-secret grant the approval should gate, open Policy → Require approval, and add the approver. Each call against the policy 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.
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", reason=e.details)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);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.details includes the approver’s reason if provided. |
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.details. |
PendingApproval never resolves | No approver was notified. | Verify the policy’s approver list and the application’s notification path. |