---
title: "Policy as code"
description: "Configure approval policies from deployment code with an operator credential, so the governed agent's runtime key cannot rewrite its own rules. Includes runnable TypeScript and Python examples."
---

Developers can automate governance. The governed agent cannot rewrite its own rules.

Approval policies decide whether a request needs a human, is auto-approved, or is auto-denied (see
[Approvals & policies](/trust-control/approvals)). This page is how to manage them from code, with two
credentials that hold deliberately different authority:

| Credential | Held by | Scopes | Can |
| --- | --- | --- | --- |
| **Operator** | Your deployment or setup code | `policies:read`, `policies:write`, plus what setup needs (for example `actions:write`) | Create, read, update, enable, disable and delete policies |
| **Runtime** | The agent | The default agent scopes | Invoke actions, request approvals. **Cannot** read or change policy |

`policies:read` and `policies:write` are [privileged scopes](/authentication#privileged-scopes): never in
the default set, and never offered on an OAuth consent screen. Keep policy configuration in the deploy
step and hand the running agent only its restricted key. Programmatic key creation is not part of this:
keys are still minted by an operator in the console.

## What a policy applies to

Targeting is **required**. A policy that does not say what it applies to is refused, because omitting the
selector used to mean "everything": a policy named for one action applied to all of them.

| `matchKind` | `matchActionName` | Applies to |
| --- | --- | --- |
| `action` | an action name | That one action. The action must exist in the policy's environment |
| `action` | omitted | Every action in the environment |
| `custom` | not allowed | Custom approval requests |
| `any` | not allowed | Every request |

An `auto_deny` that applies to every action or every request also needs `acknowledgeBroadScope: true`, so
a broad deny is always a stated choice. Policies that existed before targeting was required are kept as
they were and flagged `needsScopeReview: true` until someone confirms what they apply to. Scope is never
inferred from a policy's name.

The environment is the credential's own. There is no environment argument: a request body that names one
is rejected, an `environmentId` query that disagrees with the credential is refused, and a policy in
another environment reads as not found.

Policies are evaluated in ascending `priority`, then creation time, then id, so the order is always
deterministic. The first match governs, and an action's own `requiresApproval` flag remains a floor.

## Conditions

A policy can carry a `condition`: an expression in [CEL](https://cel.dev), the Common Expression Language,
that must also be true for the policy to apply. Use it for any rule about the request, not only amounts:

```http
POST /api/v2/approval-policies
Content-Type: application/json

{
  "name": "scale-ups need review",
  "matchKind": "action",
  "matchActionName": "scale_deployment",
  "condition": "input.replicas > 5 || input.env in [\"prod\", \"staging\"]",
  "effect": "require_approval"
}
```

A condition can read these facts:

| Fact | Type | Meaning |
| --- | --- | --- |
| `input` | map | The call's input, or a custom request's payload. Nested fields work: `input.order.total` |
| `agent.id` | string or null | The agent making the call. Null when the call names no run |
| `user.id` | string or null | The user the run acts for. Null when unattributed |
| `tags` | list of strings | A custom request's tags. Empty for actions |
| `action.name` | string or null | The action being invoked. Null for custom requests |
| `kind` | string | `"action"` or `"custom"` |
| `environment.id` | string | Trusted ID of the environment containing the policy. Payload fields cannot override it |

More examples:

```text
input.amount >= 500 && input.currency == "USD"
input.recipients.size() > 50
input.items.exists(i, i.price > 1000)
agent.id == "agt_support" && has(input.refund)
input.path.startsWith("/admin/")
```

**Checked when you save.** A policy that names one action has its condition checked against that
action's input schema, so a misspelt field (`input.ammount`) or a type mismatch (`input.currency > 5`) is
refused with a `400` that points at the problem. The result must be true or false. A condition is limited
to 2,000 characters and a bounded expression size. Evaluation runs in isolated workers with a
250 ms execution deadline and a bounded queue (up to 2 seconds waiting). Facts are limited to
10,000 nodes, 32 levels of nesting and 65,536 characters across keys and string values. Exceeding
these limits, exhausting evaluation capacity, or a worker failure requires approval. List macros
and regular expressions can be expensive even in short expressions. Regular expressions currently
use JavaScript syntax rather than CEL's RE2 syntax, so an expression may not be portable to other CEL hosts.

**A condition that cannot be evaluated requires approval.** If a request is missing a field the
condition reads, or a value has an unexpected type, the policy governs as `require_approval` and the
explanation says why. It is never skipped, so a malformed or unexpected request cannot slip past a
policy into a more lenient one below it, and it never auto-denies on an error. When a field is optional,
guard it: `has(input.amount) && input.amount >= 500`.

Numbers in `input` are CEL doubles, and comparisons with whole-number literals such as `500` work. Values
of different types are never equal: `input.priority == "1"` is false when `priority` is the number `1`.

**Drafting from plain English.** In the console, admins can describe a rule ("refunds over 500
dollars in USD") and get a proposed condition. Only your sentence and the target action's field
names, types and descriptions are sent to the model, never call data. The proposal is checked like any
other condition and is used only when you choose it. Drafts carry no charge during the beta,
with a monthly allowance per workspace:

| Plan | Policies per environment | Drafts per month |
| --- | --- | --- |
| Free (Early Beta Access) | 25 | 125 |
| Free | 10 | 50 |
| Production | 100 | 500 |
| Growth | 250 | 1,250 |
| Enterprise | Unlimited | 5,000, adjustable by contract |

A draft the model fails to produce is not counted. When drafts run out you can still write
conditions yourself.

Send `"condition": null` in an update to remove it. Conditions are stored in the policy's
[version history](#version-history) like every other field. The older `minAmount` and `conditions`
fields keep working and apply together with a condition. `minAmount: 500` means the same as
`has(input.amount) && type(input.amount) == double && input.amount >= 500`.

`minAmount` has no unit of its own. For a template action it compares `input.amount` exactly as the
agent sent it, in whatever unit that action's input uses. For an adapter action with a money impact
(a [governed effect](/execution/governed-effects)) it compares the adapter's own amount in integer
minor units of the action's currency, never the agent's claim: `minAmount: 1000` gates a 12.50 USD
`stripe.refund` (1250). When a `minAmount` policy matches, its decision explanation names the amount
it compared and in which unit, so write one policy per unit rather than one environment-wide threshold.

## Evaluate without executing

An operator with `policies:read` can test the current rules without writing a policy or dispatching an action:

```http
POST /api/v2/approval-policies/evaluate
Content-Type: application/json

{ "kind": "action", "actionName": "refund_order", "payload": { "amount": 120 } }
```

The result contains `evaluation: "policy_only"`, the credential's `environmentId`, a `decision`
with the matched policy ID/version and explanation, and explicit `limitations`. The action must
exist in that environment. Amount comes from `payload.amount`. Custom requests also use
`payload.tags`. `agentId` and `userId` are hypothetical test inputs, not runtime credentials.

This tests policy matching only. It does not resolve action version pins, apply an action's approval
floor, validate its input schema, or check halt state, budgets or runtime authority. In particular,
an unmatched result says `require_approval` as the policy default. An ungated action can still run
when no policy matches. Do not treat this response as authorization to execute.

TypeScript: `operator.approvalPolicies.evaluate(...)`. Python (sync or async):
`operator.approval_policies.evaluate(kind="action", action_name="refund_order", payload={"amount": 120})`.
To manage policies from files in your repository, see [Policy files](#policy-files). History simulation is not provided.

## Policy files

Keep policies in your repository, review what will change, and apply it in one step:

```yaml
# policies/refunds.yaml
apiVersion: anlyon.com/policies/v1
file: payments/refunds          # stable identity of this file
environment: production         # the environment's slug or id
policies:
  big-refunds:                  # stable key within the file
    name: Big refunds need two approvers
    target: { action: refund_order }
    when: input.amount >= 50000
    approvals: 2
    priority: 10
  small-refunds:
    name: Small refunds are automatic
    target: { action: refund_order }
    when: input.amount < 1000
    effect: auto_approve
    priority: 20
```

`target` is exactly one of `action: <name>`, `allActions: true`, `customRequests: true` or
`allRequests: true`. `when` is a [condition](#conditions). The other fields are `effect`
(default `require_approval`), `approvals` (default 1), `priority` (default 100), `enabled`
(default true), `agent`, `user`, `tags`, and `acknowledgeBroadScope` for an `auto_deny` that
covers every action or request. Unknown fields, duplicate keys and YAML aliases are refused, and a
file can be at most 256 KiB.

```bash
npx anlyon-policy plan policies/refunds.yaml          # what would change, plus a digest
npx anlyon-policy apply policies/refunds.yaml --digest <digest> --note "FIN-42"
npx anlyon-policy test policies/refunds.yaml policies/refunds.cases.yaml
```

The CLI ships with `@anlyonhq/sdk` and reads `ANLYON_API_KEY` (an operator key with `policies:read`,
plus `policies:write` to apply) and optionally `ANLYON_BASE_URL`. Exit codes: `0` success (plan: no
changes, test: all passed), `1` error or refusal, `2` the plan has changes, `3` a test case failed.
In CI, `apply --auto-approve` plans and applies in one step. The same operations are
`POST /api/v2/approval-policies/files/plan`, `/files/apply` and `/files/test`, and
`approvalPolicies.planFile / applyFile / testFile` in TypeScript (`plan_file / apply_file /
test_file` in Python).

- **A file only changes its own policies.** Policies made in the console, through the API or by
  another file are never adopted, changed or deleted. A name clash with one is an error in the plan.
- **File-owned policies are edited through the file.** The console and API refuse to change or
  delete them (`409 POLICY_MANAGED_BY_FILE`), so the file stays the source of truth. Remove a policy
  from the file and apply to delete it.
- **Apply is exactly what you reviewed.** The plan's digest covers the file, every policy in the
  environment and the actions it references. If any of them changed since the plan, apply refuses
  with `409 STALE_PLAN` and writes nothing. Plan again and review.
- **All or nothing.** An apply's changes and their [version history](#version-history) entries
  commit in one transaction, recorded against the key that applied them and your `--note`.
- **The plan checks everything first.** Missing actions, conditions that do not match the action's
  input schema, broad denies without acknowledgement and the plan's policy allowance are all listed
  in the plan's `errors`, and a plan with errors cannot be applied.
- **Renaming into another retained policy's name takes two applies.** First give the existing
  holder a temporary unique name and apply. Then plan and apply the intended names. The planner
  reports occupied names, including swaps, instead of accepting a plan that cannot commit.
- **Action schemas are part of the reviewed state.** Apply locks referenced action rows while
  validating and committing. A schema change invalidates the plan even before its version pointer
  has been updated. Fixture tests and apply use the same tie order for newly created policies.

A test file lists requests and what should happen to them:

```yaml
# policies/refunds.cases.yaml
cases:
  - name: big refunds need two approvers
    request: { kind: action, action: refund_order, input: { amount: 60000 } }
    expect: { effect: require_approval, policy: big-refunds }
  - name: small refunds run
    request: { kind: action, action: refund_order, input: { amount: 10 } }
    expect: { effect: auto_approve, policy: small-refunds }
```

Cases are decided with the production decision function against the environment as it would be
after applying the file: the file's policies plus every other enabled policy. `policy` is a key in
the file, another policy's id, or `null` for "no policy matched". Testing writes nothing and sends
nothing. It covers the policy decision. An action's own `requiresApproval` flag still applies
when the action runs.

## Updating: omitted means unchanged, `null` clears

```http
PATCH /api/v2/approval-policies/{policyId}
{ "priority": 3, "matchAgentId": null, "minAmount": null }
```

Leave a field out and it is unchanged. Send `null` to clear a nullable matcher (`matchActionName`,
`matchAgentId`, `matchUserId`, `matchTags`, `minAmount`, `conditions`). The result must stay consistent:
changing `matchKind` to `custom` while an action name is still set is refused, so clear it in the same
request. Enabling and disabling a policy is a patch of `enabled`.

In the TypeScript SDK an `undefined` value is left out and `null` is sent. In Python, an argument you do not
pass is left out and `None` is sent, using an internal sentinel so the two stay distinct.

An update or delete that races another change to the same policy fails with `409` instead of
overwriting it. Read the policy again and retry.

## Version history

Every create, update and delete stores an immutable snapshot of the policy in the same database
transaction as the change, so a change cannot happen without its record:

```http
GET /api/v2/approval-policies/{policyId}/versions?limit=50
GET /api/v2/approval-policies/{policyId}/versions?limit=50&before=141
GET /api/v2/approval-policies/{policyId}/versions/{version}
```

Versions come newest first, a page at a time (up to 100). For the next, older page pass `before` set
to the previous page's `pagination.nextBefore`. It is `null` on the last page, and the cursor stays
valid while new versions are added. `pagination.earliestVersion` is the oldest retained version, so a
decision that cites an older version predates retained history. Read one exact version, for example
the one an approval recorded, with `/versions/{version}`.

Each entry has the `version`, the `operation` (`created`, `updated`, `deleted` or `baseline`), the stored
`snapshot`, a `contentHash`, the `actor` that made the change, an optional `changeNote`, and `createdAt`.
Pass `changeNote` (up to 1000 characters) when you create or update a policy to record why.

- **The actor comes from the credential**, never from the request body: the API key or signed-in user
  that made the change. Changes whose writer was not recorded report `actor: null`.
- **Deleting a policy keeps its history.** The last entry is a `deleted` tombstone, and
  `/versions` still answers after the policy itself returns `404`. This makes the version recorded on
  an approval or invocation traceable to the exact rules that governed it.
- **History starts at a baseline.** A policy that existed before history was enabled starts with a
  `baseline` entry holding the version current at that moment. Versions overwritten before that
  were never recorded and are not reconstructed.
- **Entries are append-only.** The database rejects edits and deletions of history entries, not
  only the API. Deleting the environment or workspace deletes its history with it.
- `snapshot` is the stored row with snake_case field names. `contentHash` is a SHA-256 integrity
  digest of that stored snapshot, not a signature.

Reading history needs `policies:read` and is scoped to the credential's environment. The console shows
the same history on each policy, and an approval's policy reference links to the version it used.
TypeScript: `operator.approvalPolicies.versions(policyId, { limit, before })` and
`operator.approvalPolicies.version(policyId, 7)`. Python (sync or async):
`operator.approval_policies.versions(policy_id, limit=50, before=141)` and
`operator.approval_policies.version(policy_id, 7)`.

## Retrying a create: a 409 is not a replay

`POST /api/v2/approval-policies` does **not** accept an `Idempotency-Key`. A policy name is unique within an
environment, so the guard against creating one twice is a `409 Conflict` on a duplicate name. That is a refusal,
not a replay of the earlier request: the response does not tell you whether the policy that exists is the one
you meant, or a different one that happens to share the name.

If a create times out or the connection drops, you do not know whether it applied. Do not blindly retry and treat
a 409 as success. Read, compare, and reconcile:

1. `GET /api/v2/approval-policies` (add `?actionName=` to narrow it) and find the policy by `name`.
2. Compare what you intended with what exists: `match.kind`, `match.actionName`, `effect`, `requiredApprovals`,
   `priority`, `enabled`, and any other matcher you set.
3. If it matches, you are done. If it differs, `PATCH` it to the intended configuration (a field you omit is
   unchanged, `null` clears a matcher), rather than creating a second one.

```ts
async function ensurePolicy(client: Client, want: CreateApprovalPolicyRequest) {
  try {
    return (await client.approvalPolicies.create(want)).data!;
  } catch (error) {
    if ((error as { status?: number }).status !== 409) throw error;
    const { data } = await client.approvalPolicies.list();
    const existing = data!.find((p) => p.name === want.name)!;
    // Reconcile: patch only what differs. `null` clears a matcher the deployment no longer sets.
    return (await client.approvalPolicies.update(existing.id, {
      matchKind: want.matchKind,
      matchActionName: want.matchActionName ?? null,
      effect: want.effect,
      priority: want.priority,
    })).data!;
  }
}
```

Run policy configuration from one place, and make it converge on the intended state rather than assume it starts empty.

## The decision, from the invocation alone

When a policy denies an invocation, the invocation carries a persisted, secret-free `decision`:

```json
{
  "status": "denied",
  "responseStatus": null,
  "body": null,
  "decision": {
    "source": "policy",
    "code": "POLICY_DENIED",
    "effect": "denied",
    "explanation": "Denied by policy \"refund_order_denied\", version 2. The request was not sent.",
    "policy": { "id": "apol_…", "name": "refund_order_denied", "version": 2 },
    "decidedAt": "2026-09-20T08:14:03.221Z"
  }
}
```

- It is a **snapshot**. Editing or deleting the policy later does not change it.
- No response is invented for a request that was never sent: `responseStatus` and `body` stay `null`.
- It contains no conditions, matchers, approver identities, request input or secrets.
- It is on the invocation itself, in creation, retrieval and listing, and needs no `approvals:read`.
- `source` is `policy`, `human` (an approver approved or denied, and an approver can be an API key), `system` (the approval expired) or `assistant` (an Anlyon Vigil approval).

On the approval, `decisionOrigin` records the same fact (`human`, `policy`, `system` or `assistant`, and null while pending),
and `GET /api/v2/approvals?decisionOrigin=automated` lists decisions nobody had to make. Approvals decided before origin was recorded, whose evidence does not settle by whom, report `unknown`. That is never guessed. The console inbox
opens on what needs a person, with **Approver history** and **Automated decisions** as separate views.

## Check your credential at startup

`GET /api/v2/auth/identity` (`client.auth.identity()` in both SDKs) returns only the caller's own
credential: its type, key id (API keys only), workspace and environment, effective scopes and expiry. It
needs no scope beyond being authenticated, is never cached, and never returns a secret, a hash or another
key.

```ts
const { data } = await client.auth.identity();
const missing = ['actions:invoke'].filter((s) => !data.scopes?.includes(s));
const tooBroad = ['policies:write', 'approvals:decide'].filter((s) => data.scopes?.includes(s));
if (missing.length || tooBroad.length) throw new Error(`bad key: missing ${missing}, too broad ${tooBroad}`);
```

Holding a scope is not proof a call will run. Policies, budgets, environment halt and quotas still apply
when the request is made. Use the returned `apiKeyId` with the budget endpoints (`/api/v2/budgets/{apiKeyId}`),
subject to their own authorization.

## Example: scope a deny, then run into it

An operator denies `refund_order`. A restricted runtime key calls `get_order` and succeeds, then calls
`refund_order` and gets a structured explanation.

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

const operator = new Client({ apiKey: process.env.ANLYON_OPERATOR_KEY! });
const runtime = new Client({ apiKey: process.env.ANLYON_RUNTIME_KEY! });

// 1. Deployment code, with the operator credential.
const { data: policy } = await operator.approvalPolicies.create({
  name: 'refund_order_denied',
  matchKind: 'action',
  matchActionName: 'refund_order',
  effect: 'auto_deny',
});

// 2. The agent, with its restricted key. get_order is untouched.
const read = (await runtime.actions.invoke('get_order', { orderId: 'ord_1' })).data;
console.log(read.status); // "succeeded"

// 3. refund_order is denied, and says why.
const refund = (await runtime.actions.invoke('refund_order', { orderId: 'ord_1' })).data;
console.log(refund.status, refund.decision?.explanation);
// denied  Denied by policy "refund_order_denied", version 1. The request was not sent.
```

```python Python
import os
from anlyon import Client

operator = Client(api_key=os.environ["ANLYON_OPERATOR_KEY"])
runtime = Client(api_key=os.environ["ANLYON_RUNTIME_KEY"])

# 1. Deployment code, with the operator credential.
policy = operator.approval_policies.create(
    name="refund_order_denied",
    match_kind="action",
    match_action_name="refund_order",
    effect="auto_deny",
).data

# 2. The agent, with its restricted key. get_order is untouched.
read = runtime.actions.invoke("get_order", {"orderId": "ord_1"}).data
print(read["status"])  # "succeeded"

# 3. refund_order is denied, and says why.
refund = runtime.actions.invoke("refund_order", {"orderId": "ord_1"}).data
print(refund["status"], refund["decision"]["explanation"])
```

Both are shipped as runnable, self-checking files (`examples/policy-governance.ts` in the TypeScript SDK,
`examples/policy_governance.py` in the Python SDK), which also confirm that the runtime key is refused when it
tries to disable the policy.

**How it behaves**

Policies constrain requests that go through Anlyon. An [impact limit](/execution/impact-limits) caps the money
an approved action moves. The console enforces workspace roles: a member can read policies and an admin can
change them. An operator key's authority is its scopes, and it is bound to one environment.
