Concepts
Autonomy and action policy
Every call to a tool you have declared resolves to one of three answers — per call, against the arguments of that particular call. A tool you have not declared is not governed at all, and that is the first thing to know.
| Answer | What happens |
|---|---|
| refuse | The tool never runs. Your agent is told why and explains to the user. |
| ask | A person decides, on an approval card, and the run pauses. |
| allow | It runs unattended, in the same turn, and is recorded as approved automatically. |
You declare the defaults in your manifest. Your users — and their tenant admin — can tighten them, and can loosen them only as far as you allow.
Tiers are the coarse setting
A tier decides a tool that acts. A read is never gated.
| Tier | Decides | Use it when |
|---|---|---|
| t0 | refuse | the agent is read-only, or this tool must never run in this deployment |
| t1 | ask | the default — a person sees a card and decides |
| t2 | allow | routine and reversible, or bounded by a rule |
default_tier on the manifest sets it for every tool the target has, and defaults to "t1"; a tool's own tier overrides it. A tier decides the outcome, and something still has to carry it out: refuse and allow are enforced by the platform whatever your tool looks like, while ask reaches a person by one of two routes, below.
Only a declared tool is governed
Listing a tool in MANIFEST.actions is the statement that it acts, and it is the only thing that puts the tool under policy. Everything on the rest of this page — tiers, rules, stored policies, ceilings, approval cards — applies to declared tools and to nothing else.
| t0 | t1 | t2 | |
|---|---|---|---|
Declared in actions | refused | asks a person | runs, recorded as approved automatically |
| Not declared | refused, by default | runs, unasked and unrecorded | runs, unasked and unrecorded |
An MCP tool you did not declare goes out unasked
At t1 and t2 an undeclared tool is treated as a read, and that includes a tool on an MCP server you allowed with tool_filter and did not list in actions. The call goes to that server with nobody asked, no pending action and no record of a decision. tool_filter bounds which tools exist; actions is what governs them. Declare every tool that changes something outside the conversation.
t0 is the one tier that reaches an undeclared tool, and what it does there is unlisted_tools on the manifest. The default, "action", refuses it: read-only has to mean read-only, and an undeclared tool is the unclassified one rather than the one known to be safe. "read" lets undeclared tools run at t0 too — choose it only if you are sure every tool you have not named is harmless, including whatever an MCP server adds next. It changes nothing at t1 or t2.
Who raises the question
When a declared tool resolves to ask, what actually raises the question depends on whether the tool has a body you can edit — not on how you declared it.
| Your declared tool | Who asks | What you write |
|---|---|---|
| A Python function in your repo | the tool | validate, then call request_confirmation with your own wording |
| A tool on an MCP server | the platform | the actions entry, and nothing else — there is no body to ask from |
For a declared MCP tool the platform raises the confirmation on its behalf, naming the tool and the arguments it was called with — Allow issue_refund to run? (room: 204, amount: 2500) — and that call does not leave your cluster until somebody answers. Everything downstream is identical to a tool that asked for itself. actions is keyed on the server's own tool name, the one you list in tool_filter, not on the binding's ref.
A local tool with a body that acts without asking — it assigns a room, writes a row — runs at t1. The platform cannot tell it apart from one that asks from inside its body, so it treats that as your decision, taken in code you own. Declare it anyway, so t0 can refuse it and a stored policy can never raise it past your ceiling.
Ask from your own body, not with the framework's flag
Both pause. But the framework's require_confirmation=True produces its own hint — “Please approve or reject the tool call X() by responding with a FunctionResponse with an expected ToolConfirmation payload” — and that sentence is shown to your user exactly as written. Asking yourself is also what lets you validate first, so nothing impossible is put up for approval. And the body runs twice, once to ask and once to act: anything that changes state above your confirmation check happens whether or not a person said yes.
Declaring a tool
actions maps a tool name to an ActionPolicy with four optional fields: labels (categories for filtering an approval inbox, lower-case letters, digits and hyphens, up to 32 characters, checked when the manifest is built), tier, ceiling and rules.
from agentkit_core.manifest import ActionPolicy, AgentManifest, Rule
MANIFEST = AgentManifest(
slug="hotel-concierge",
# ... version, display_name, billing and the rest ...
default_tier="t1", # ask, unless a tool says otherwise
actions={
"issue_refund": ActionPolicy(
labels=["money"],
tier="t2", # routine refunds run themselves
ceiling="t2", # and a stored policy may keep it so
rules=[
# ...but a big one waits for a person.
Rule(when={"field": "amount", "op": ">", "value": 1000},
outcome="ask"),
],
),
"check_out": ActionPolicy(
labels=["billing"],
rules=[Rule(when={"field": "balance_due", "op": ">", "value": 0},
outcome="refuse")],
),
# Asks nobody, and still declared: it assigns a room and issues a key.
"guest_check_in": ActionPolicy(labels=["front-desk"]),
},
)Labels are declared in code rather than typed by a user because what a tool does is a property of the tool: a category only stays meaningful if every action of a kind carries the same one.
Rules test the call's own arguments
A tier is the same answer every time. A rule is a condition over the arguments the model is about to pass — exactly three keys: a field, which is a dotted path into the arguments; an op; and a value, which is plain data and never code. The first rule that matches wins, and rules are judged before the tier. That is what lets a small refund run itself while a large one waits for a person.
The arguments a rule tests are the same ones the tool receives, so the figure your rule tested is the figure the approver reads on the card. There is no second copy to drift.
# "only these currencies run themselves" — the ARGUMENT is in your list
Rule(when={"field": "currency", "op": "in", "value": ["GBP", "EUR"]},
outcome="allow")
# "ask when this exact recipient is on the list" — your VALUE is in the argument
Rule(when={"field": "recipients", "op": "contains", "value": "finance@acme.test"},
outcome="ask")
# a dotted path reaches into a nested argument
Rule(when={"field": "address.country", "op": "!=", "value": "GB"},
outcome="ask")
# no condition at all: this outcome, for every call
Rule(when=None, outcome="ask")Operators
Eight, and the set is closed: an unknown op fails when the manifest is built, so a typo fails the deploy rather than silently never matching.
| op | True when | Notes |
|---|---|---|
| == | the argument equals your value | any type |
| != | the argument differs from your value | any type |
| > | the argument is greater than your value | never on a boolean |
| >= | the argument is at least your value | never on a boolean |
| < | the argument is less than your value | never on a boolean |
| <= | the argument is at most your value | never on a boolean |
| in | the argument is one of your values | your value is a list or a string |
| contains | your value is inside the argument | the argument is a list, string or object |
in and contains are opposites, and contains on a list is exact
in asks whether the argument is one of your values; contains asks whether your value is inside the argument — an element of a list, a substring of a string, a key of an object. On a list that is exact membership, not a substring search: testing for "@external.test" against ["ada@external.test"] is false. A rule written that way never matches and nothing reports it, because “did not match” is a legitimate answer. There is no operator for “any element contains this”; have the tool take the fact you want to test as its own argument and write a threshold against that.
An ordered comparison on a boolean is refused rather than evaluated — in Python True > 0, which is a threshold nobody meant to write — so it counts as a condition that cannot be judged, and the call asks.
Two things nobody can override
Both are re-read from your manifest on every call rather than from any stored row, and both are stripped on write as well as ignored on read — so a row in a database can neither lift its own cap nor drop a prohibition you wrote.
A ceiling is the most autonomy any stored policy may ever grant the tool. Anyone signed in can grant themselves a standing approval, so without one a single card could make a tool that moves money run unattended for everyone, permanently. A ceiling only ever holds a policy down; it never grants anything. Changing one is a deploy, which is the point.
A declared refusal — a rule whose outcome is refuse — is a floor. It is judged before any stored policy, so your code can forbid what no user setting reaches past: “never check out a guest with a balance owing” stays true however generous somebody's own settings are. The reason it gives names the condition, so the agent can tell the person what would change it.
The order it is resolved in
- A refusal your manifest declaresrefuse
A floor. Judged first, so nothing reaches past it — not a stored policy, not an admin, not a tier.
- The stricter of the stored policiesclamped to your ceiling
Two scopes can store one: the person the run belongs to, and their tenant admin's cap. The stricter wins (refuse, then ask, then allow), so anyone may lower their agent's autonomy freely and may raise it only as far as their tenant allows. A stored policy's own rules and tier are used in place of yours.
- Your manifest's own rulesfirst match wins
The conditions you wrote, in the order you wrote them. A condition that does not match moves on to the next rule.
- The tool's tier, else the target's defaultthat tier's answer
The coarse setting, reached only when no rule had an opinion.
A scope with no stored policy has no opinion
It does not vote at your manifest's default. If an untouched policy voted, every user who had never chosen anything would cancel their tenant's grant — and the admin setting would appear broken for everybody rather than capped. A row exists only because somebody chose something.
How it fails
A condition that cannot be judged resolves to ask, never to allow.
| When | Answer |
|---|---|
| the field is not in the call's arguments | ask |
| the types do not compare — a string against a number, or an ordered comparison on a boolean | ask |
| the policy store cannot be reached when a run starts | the policies last fetched keep applying; with none cached, your manifest decides alone |
| those cached policies are more than five minutes old | they stop granting — anything they would allow asks — and keep refusing. Permission expires; caution does not. |
A condition that does not match is a different thing from one that cannot be judged: the first moves on to the next rule, the second stops the search and asks a person. Note what the store row means: a pod that starts while the store is down has nothing cached, so for those runs a stored policy — including a tenant cap stricter than your manifest — does not apply until the store is back.
Changing it at runtime
Your users and their admins do this, not you — and a stored policy replaces a tool's rules rather than adding to them. So anything editing one must read the existing policy and write it back with the change substituted; building a fresh policy silently drops every other condition that scope carried.
A policy is stored per tenant, project, person, target and tool. The project is part of the key because an agent slug is unique within a project and not within a tenant: without it, a grant made while testing in one project would apply to the same agent in another. A pod reads the policies for a run when the run starts and reuses them for up to thirty seconds, so a change applies to runs that start after it, not to one already going.
Reading and setting it from your backend
The SDK's autonomy namespace does the read-modify-write for you: rule substitutes one condition and keeps the rest, tier moves only the tier, and set is the raw replace — documented, and not what you want. policies returns both scopes split into mine and tenant, because a caller that read them as one list would write a tenant cap back as their own, and reset returns a tool to having no opinion.
Every one of those takes a user, which defaults to the caller. Pass eu:<id> to read or change what one of your end users has granted:
| Naming | Who may | Why |
|---|---|---|
| nobody (the default) | anyone | your own policy |
eu:<id>, one of your end users | any credential for the project | they cannot act for themselves; the same authority as their wallet |
| a colleague's user id | a tenant admin | a member setting another member's autonomy is the tenant acting on a person |
| * | nobody | it is the tenant scope's marker; the tenant cap is set with scope="tenant", which needs an admin |
Your end users cannot reach their own policy, and you can
Their credential is a ticket, which opens the event stream and nothing else — so they cannot read or change their own rules, and your backend is the only thing that can act for them by naming them. Their approval cards are recorded against them too, so the inbox's default scope finds none of them: list and decide those with the same user.
Where to go next
Approvals and autonomy in the SDK has the calls themselves, and the autonomy API has the inbox, the decide routes and the stored shape. Approvals is for asking somebody who is not in the conversation, and sessions and runs covers what a paused run looks like from a client.