Skip to content
Anlyon
Esc
↑↓navigate↵open⌘Jpreview
On this page

Policy as code

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). 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: 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, 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:

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:

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

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. History simulation is not provided.

Policy files

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

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

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

# 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

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:

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

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

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.

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

Was this page helpful?