Skip to main content
A policy is data, not code. It always terminates, it is validated when you write it rather than when an agent is waiting, it diffs in review, and any UI can render it. A policy engine standing in front of money should be analysable, not Turing-complete.

Your first policy

A policy carries two things: typed columns for the common cases, and a general rules array for everything else. The columns are shorthand that compiles into the same rules, so use them when they fit.
Never more than 500inoneaction,holdanythingover500 in one action, hold anything over 200 for a person, and never more than $2,000 in any rolling hour. The third rule is the one that catches a retry loop.

The typed columns, and the rule name each produces

This mapping matters, because the name you see in decision.rule is not the underlying rule kind: Write the same limit in the rules array instead and it reports under its rule_name, which defaults to the kind. Both are fine; just do not expect a cap set through per_transaction_cap_cents to come back as cost_cap.

Rule kinds

That table is the whole registry. There is no way to define a rule kind of your own, and no kind outside those eight will save. Every kind takes an optional rule_name, echoed back in the decision, and every kind except action_allowlist takes an optional action_types array to scope it; an empty array means all action types. Every rule is assigned a stable id on write, so a decision read months later can still name the exact rule that produced it. An unknown kind, or a missing setting, is rejected when the policy is written. Never when an agent is waiting on an answer. Two things the rule kinds deliberately do not do, because people ask. rate_limit and spend_window count across the whole agent and can only be narrowed by action_types: there is no grouping by counterparty, by resource, or by a metadata key. And nothing reads the clock beyond a rolling window, so there is no time-of-day or day-of-week condition. Where a fact like that matters, compute it on your side and send it in metadata, which a condition rule can read.
An allow array that is empty is a no-op, not a deny-all. To deny by default, use mode: "allowlist" instead.
Window rules count both action intents in pending, requires_approval or completed, and payment intents in pending, requires_approval or executed. Spend on one consumes the other’s headroom.

The condition language

condition is the open one: a boolean expression over the action, with an effect.
Effects are deny (the default), allow, or require_approval. Combinators are all_of, any_of and not, nestable to ten levels. An empty all_of or any_of is rejected at write time. Fields are action_type, resource, cost_cents, currency, agent_id, agent_mode, budget_id, and any metadata.* dot path. A metadata.* rule reads the metadata object on the request, on both POST /control/v1/agent/actions and POST /control/v1/agent/payment_intents. On the payment endpoint decision_context is provenance and is never evaluated, so send anything a rule must see as metadata. A payment is evaluated as action_type payment.create with the counterparty as its resource, so the same rule covers both endpoints. Operators are eq, ne, gt, gte, lt, lte, in, not_in, contains, starts_with, ends_with, exists. A predicate compares to a literal value, or to another field on the same request with value_field, which accepts exactly the names the left side accepts. Sending both is rejected at write time. That is how a refund gets capped at the order it belongs to: see stop an agent over-refunding. A missing field never raises. It fails to match. Unknown fields and operators are rejected at write time. The ordered comparisons (gt, gte, lt, lte) are numeric. A non-numeric operand makes the comparison false rather than an error, so a typo in a metadata.* path silently fails to match rather than blowing up. Use exists when you need to tell “absent” from “false”.

Blocklist and allowlist

A policy is blocklist by default: the action is permitted unless a rule denies it. Set mode: "allowlist" and it inverts. The action is denied unless one of that policy’s allow rules matches, reported as the rule not_allowlisted. A deny rule still overrides an allow. Each allowlist policy is an independent gate. Attach two and both must be satisfied.
Allowlist mode is the right default for an agent that touches real money. Start by denying everything and naming what you permit, rather than trying to enumerate every way an agent could go wrong.

Reusable modules

Modules are named rule sets scoped to a workspace. A policy imports them by name and they compile in ahead of its own rules.
Modules are addressed by name, not by id, so GET /control/v1/workspaces/{slug}/policy_modules/vendor-allowlist reads one back. Editing a module changes every policy importing it. A policy importing an unknown module will not save, and a module still imported by a policy will not delete. Modules do not import other modules, so composition is one level deep and cannot cycle.

Test before you apply

Never guess what a policy does. Preview a draft you have not saved:
200 OK
The denial reports limit_cents, not the per_transaction_cap_cents you wrote. The column name is the input; detail speaks in the compiled rule’s terms. Nothing is persisted: no policy, no budget, no intent, no audit event. imports resolve against the workspace’s real saved modules, so you can preview a draft that builds on modules already in place. session_budget_cents sets the synthetic budget and defaults to 100000. Previewing needs only the read capability, deliberately: it is not a mutation. The same evaluation is available to your agent at POST /control/v1/agent/actions/simulate, which does record an action.simulated audit event.

Next steps

Holding spend for a person

What approval_threshold produces, and how it resolves.

Spend tokens

A single-use ceiling that narrows a policy for one payment.

Errors

Every rule that can appear in decision.rule.

API reference

The full policy schema.