Local approval gates
When a call cannot move into Anlyon: wrap one of your own functions so it waits for a human decision. What that buys you, and what it does not.
Your agent can already do dangerous things: refund customers, send emails, change production data. A local approval gate pauses exactly those calls until a human decides, and records who decided what, when, and why.
This guide takes an existing function and puts a human in front of it. No framework changes, no rewrite: the gate wraps the function you already have.
1. Install the SDK
npm install @anlyonhq/sdkpip install anlyon2. Create a client
Create an API key in the dashboard (Settings → API Keys). Keys belong to one environment, so a staging key cannot create production approvals.
import { Client } from '@anlyonhq/sdk';
const client = new Client({ apiKey: process.env.ANLYON_API_KEY! });import os
from anlyon import Client
client = Client(api_key=os.environ["ANLYON_API_KEY"])3. Gate the function
Two shapes, and they hold different things.
approvals.gatewraps a function in your process: Anlyon records the request, waits for the decision, and the wrapper calls your function only if it was approved. Your credentials stay in your process. Anlyon does not see them, and it cannot attest that the function ran or didn’t. That is what this guide is about.The other shape is a hosted Action: Anlyon holds the credential, executes the approved request itself against the destination the action fixed at authoring time, and records what it sent. Reach for that one when the point is that the model never has the key.
import { ApprovalDeniedError, ApprovalPendingError } from '@anlyonhq/sdk';
// Any function: sync or async, any arguments.
async function refund(orderId: string, amount: number) {
// ... your existing refund logic ...
}
const guardedRefund = client.approvals.gate(
{
title: 'Refund a customer?',
description: 'Requested by the support agent. Refunds are irreversible.',
timeoutMs: 10 * 60_000, // wait up to 10 minutes for a decision
},
refund,
);
try {
// Blocks until a human approves; then (and only then) refund() runs.
await guardedRefund('ord_42', 129.99);
} catch (err) {
if (err instanceof ApprovalDeniedError) {
console.log('Denied:', err.approval.decisionNote);
} else if (err instanceof ApprovalPendingError) {
console.log('Nobody decided in time. The approval is still open:', err.approval.id);
} else {
throw err;
}
}from anlyon import ApprovalDeniedError, ApprovalPendingError
@client.approvals.gate(
title="Refund a customer?",
description="Requested by the support agent. Refunds are irreversible.",
timeout_seconds=600, # wait up to 10 minutes for a decision
)
def refund(order_id: str, amount: float) -> None:
... # your existing refund logic
try:
# Blocks until a human approves; then (and only then) refund() runs.
refund("ord_42", 129.99)
except ApprovalDeniedError as err:
print("Denied:", err.approval.get("decisionNote"))
except ApprovalPendingError as err:
print("Nobody decided in time. The approval is still open:", err.approval["id"])The gate defaults the approval’s payload to the call’s arguments, so the approver sees exactly what they are approving. Pass payload explicitly to show something friendlier (and to keep sensitive arguments out of the approval).
4. Decide
Decisions arrive in the dashboard Approvals inbox in realtime, or through the API from your own tooling:
// From a second, decision-scoped key. A key can never approve its own request.
await client.approvals.approve('apr_xxx', { note: 'Verified with the customer.' });
// or: await client.approvals.deny('apr_xxx', { note: 'Amount too large.' });# From a second, decision-scoped key. A key can never approve its own request.
client.approvals.approve("apr_xxx", note="Verified with the customer.")
# or: client.approvals.deny("apr_xxx", note="Amount too large.")Two rules are enforced server-side, always:
- Deciding requires the separate
approvals:decidescope, which is not granted by default. And the credential that requested an approval can never decide it, whatever scopes it holds and whatever any policy says. This is a rule about the credential, not the person. Any workspace member can decide in the dashboard. If a decision should not need a human at all, express that as anauto_approvepolicy naming the action. That decision is made in advance by an admin and recorded against a policy version. The requester does not vote on itself. - Every decision is recorded: decider, timestamp, note, and the policy (and its version) that governed the approval.
5. Let policies do the routine work
Approval policies evaluate at request time, per environment, first match wins:
auto_approvethe low-risk cases (small refunds, staging environments) so humans only see what matters. An action flaggedrequiresApprovalkeeps its gate unless the policy names it withmatchActionName. That flag is a floor, and a policy must name the action to lower it.require_approvalwith N-of-M approvers for the big ones. N counts distinct deciders, and what a decider is depends on how they authenticate: an API-key decision counts per credential, a dashboard or OAuth decision counts per user. Two keys held by the same person are two deciders, so require dashboard decisions when you mean two people.auto_denywhat should never happen.
Create them in the dashboard under Approval Policies. Every governed approval records which policy version decided its routing and a human-readable explanation. The EU AI Act mapping says how these records relate to human-oversight obligations, and what they do not establish.
What comes with it
- Tracing. Pass
runIdto the gate and the approval emitsapproval_waitandapproval_decisionspans into that run’s trace, so the wait is visible in the same timeline as the model and tool calls. - Expiry. Approvals expire (24 hours by default) rather than dangle. An expired approval raises the same
ApprovalDeniedError. - Idempotency. Decision endpoints accept an
Idempotency-Key, so a retried approve does not run a gated action twice. - The kill switch. Halting an environment blocks hosted action executions in it, including ones already approved. It does not stop a function your own process runs behind a local gate.

