---
title: "Local approval gates"
description: "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.

**Read this first: this is the fallback path**

Anlyon has two ways to put a human in front of a production call, and they are not equivalent.

**Hosted actions** are the primary path. You define the call once, your agent invokes it by name, and *Anlyon* makes the request using a credential the model never sees. The approval holds a call Anlyon is about to make. See [Actions](/execution/actions) and [the execution model](/execution).

**The local gate on this page** wraps one of *your* functions. The SDK creates an approval and waits for the decision, and then your process runs your function with your credential. That gives you the human decision and the record. It does not move your credential into Anlyon, and Anlyon cannot attest that your function ran, or did not. The gate covers only calls that go through the wrapper.

Use this when the call genuinely cannot move: a native SDK with no HTTP equivalent, a database write, something already deep inside your own code. When the call *can* move, move it.

## 1. Install the SDK

```bash TypeScript
npm install @anlyonhq/sdk
```

```bash Python
pip install anlyon
```

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

```typescript TypeScript
import { Client } from '@anlyonhq/sdk';

const client = new Client({ apiKey: process.env.ANLYON_API_KEY! });
```

```python Python
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.gate` wraps 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](/execution/actions): 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.

```typescript TypeScript
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;
  }
}
```

```python Python
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:

```typescript TypeScript
// 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.' });
```

```python Python
# 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:decide` scope**, 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 an `auto_approve` policy 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_approve` the low-risk cases (small refunds, staging environments) so humans only see what matters. An action flagged `requiresApproval` keeps its gate unless the policy names it with `matchActionName`. That flag is a floor, and a policy must name the action to lower it.
- `require_approval` with **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_deny` what 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](/trust-control/compliance) says how these records relate to human-oversight obligations, and what they do not establish.

## What comes with it

- **Tracing.** Pass `runId` to the gate and the approval emits `approval_wait` and `approval_decision` spans 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.

## Next steps

- [Authentication and scopes](/authentication)
- [Getting started](/guides/getting-started)
- [Events and webhooks](/guides/webhooks)
