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.

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.

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. 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.

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
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);

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.

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.details includes the approver’s reason if provided.
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.details.
PendingApproval never resolvesNo approver was notified.Verify the policy’s approver list and the application’s notification path.

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.