api-policy.yaml¶
The complete schema of the outbound API policy: every key with its type and default, how operations and denials match a call, and the approval block.
api-policy.yaml sits at the project root (API_POLICY_PATH moves it). The same rules are
applied by create --api-policy, lint, every graph-agents-cli api command and the running
agent: one block of code is shared byte for byte between the CLI and the generated
app/app_utils/api_client.py, and a test keeps the copies identical. Why and how to use the
policy is in Outbound API policy; change it with
graph-agents-cli api rather than by hand.
A complete example¶
Every key on this page, in one API. graph-agents-cli api check accepts it, with the
OpenAPI file it names and a tool declaring the three allowed calls:
apis:
orders: # the name: ^[a-z][a-z0-9_]{0,31}$
base_url_env: ORDERS_API_BASE_URL # required
auth: bearer # required: none | bearer | forward | exchange
token_env: ORDERS_API_TOKEN # required with auth: bearer
allowed_methods: [GET, HEAD, POST, PUT, PATCH, DELETE] # required
allowed_operations: # optional: only these operations
- operationId: listOrders
path: /orders
methods: [GET]
- operationId: createOrder
path: /orders
methods: [POST]
- operationId: updateOrder
path: /orders/{order_id}
methods: [PATCH]
denied_operations: # optional: denials always win
- operationId: deleteOrder
path: /orders/{order_id}
methods: [DELETE]
openapi: specs/orders.yaml # optional: lint checks calls against it
timeouts_ms: {connect: 2000, read: 5000}
pagination: {page_size_param: pageSize, max_page_size: 200}
limits: {max_calls_per_run: 20, rate_per_minute: 120, max_response_bytes: 1048576}
approval: # optional: a person approves first
required_for: {methods: [POST, PATCH, DELETE]}
approvers: [requester]
timeout_s: 900
$ graph-agents-cli api check
┃ Tool ┃ API ┃ Method ┃ Operation ┃ Status ┃ Reason ┃ Approval
│ orders.py │ orders │ GET │ listOrders /orders │ allowed │ spec: GET /orders │ -
│ orders.py │ orders │ POST │ createOrder /orders │ allowed │ spec: POST /orders │ requester (approval.required_for.methods ['POST', 'PATCH', 'DELETE']; expires after 900 s)
│ orders.py │ orders │ PATCH │ updateOrder /orders/{order_id} │ allowed │ spec: PATCH /orders/{order_id} │ requester (approval.required_for.methods ['POST', 'PATCH', 'DELETE']; expires after 900 s)
2 declared call(s) wait for a human approval before they are sent (the Approval column: who approves, and why).
All declared API calls are allowed.
Structure¶
The document is a mapping with one key, apis: a non-empty mapping from API name to its
settings. An API name is 1-32 lowercase letters, digits and underscores, starting with a
letter (^[a-z][a-z0-9_]{0,31}$); tools name it in get_client("<name>").
Strict by design
Unknown keys and repeated keys are errors at every level, so a typo can never widen
access. YAML merge keys (<<: *anchor) are accepted, though the api commands refuse
to edit a file that overrides a key after one
(KI-045).
API keys¶
| Key | Value | Meaning |
|---|---|---|
description |
text; optional | What the API is for: 1-300 characters without control characters. Any API may have one; api show prints it. |
protocol |
http | jsonrpc | a2a; default http |
How calls are judged. jsonrpc and a2a also judge each POST by the JSON-RPC request it sends, read from the body. |
a2a |
{path}; required with protocol: a2a |
The agent's A2A endpoint, a literal path such as /a2a/orders. Valid only with protocol: a2a. |
base_url_env |
env var name; required | The variable holding the API's base URL, set per environment. A path prefix in the URL (https://host/v2) is kept: call paths are joined under it. |
auth |
none | bearer | forward | exchange; required |
none sends no credential. bearer sends Authorization: Bearer $<token_. forward sends the caller's own credential, and exchange a token exchanged for it (see below); both are refused under the langgraph-server runtime, which would persist the caller's credentials. |
token_env |
env var name; required with bearer |
The variable holding the token; valid only with auth: bearer. create and api add add it to the manifest's secrets.keys. |
forward_header |
header name; default Authorization |
The header auth: forward or auth: exchange sends the credential in; valid only with those. exchange sends Bearer <token>. |
forward_audience |
an audience; optional | auth: forward only: when the caller has no per-API credential, forward the caller's own verified token if its aud names this audience (the issuer minted it for the target too). 1-256 characters without spaces, commas or control characters. |
exchange |
{audience, scope, resource, allow_actorless}; required with exchange |
The token to ask the issuer for, in exchange for the caller's own (RFC 8693). Valid only with auth: exchange. |
allowed_methods |
list of methods; required | The methods the API allows: GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS (any case), or ["*"] alone for every method. There is no default access. |
allowed_ |
list of operations; default every operation within allowed_methods |
When present, a call must also match one entry. An empty list is refused: omit the key instead. |
denied_ |
list of operations; default none | Endpoints refused whatever else allows them. |
openapi |
file path | An OpenAPI spec, relative to the project root. lint checks declared calls against it, and api allow, api deny and api approval --operations fill in an operation's method and path from it. |
timeouts_ms |
{connect, read}; default {connect: 2000, read: 5000} |
Positive integers, in milliseconds. |
pagination |
{page_; default no cap |
Both keys required when present: the query parameter that sets a page size, and its cap (a positive integer). |
limits |
{max_; default no limits |
At least one key; integers of 1 or more. max_ counts the calls to this API in one agent run; rate_per_minute is a token bucket; both are per process. max_ (at most 67108864, 64 MiB) caps each answer: its body is read, decoded, only up to that many bytes, and past it the answer is discarded and the call fails. A capped call accepts gzip and deflate only, decoded within the cap; an answer in another content encoding is refused. |
approval |
a rule, or a list of rules; default no approval | Calls a person approves before they are sent. |
auth: forward sends attributes["credentials"][<api name>] of the caller's principal, which
a per-user auth policy sets (see Authentication), or, with
forward_audience, Bearer <the caller's own token> when that token's aud names the
audience; a caller with neither sends nothing. The policy's credential always overrides a
header the tool passes, and a tool header of that name is never bound by an approval.
exchange¶
auth: exchange calls the API with a token the issuer's token endpoint (TOKEN_EXCHANGE_URL)
mints for it in exchange for the caller's own verified token (RFC 8693 token exchange; see
the guide):
| Key | Value | Meaning |
|---|---|---|
audience |
an audience; required | The audience asked for: the target's AUTH_. 1-256 characters without spaces, commas or control characters. |
scope |
scopes separated by single spaces; optional | The scopes asked for (RFC 6749 scope tokens: printable ASCII without " and \). |
resource |
an absolute URI without a fragment; optional | The target's resource indicator (RFC 8707). |
allow_actorless |
true or false; optional, default false |
Whether an exchanged token that names no actor may be sent. By default the calling agent refuses one (nothing is sent): a JWT without the actor claim (act, or the one AUTH_ names), or a token it cannot read as a JWT (opaque, encrypted). Set true only when the agent behind the API sets AUTH_ and lists this agent as client:<its client id> in AUTH_. |
Unknown keys are refused. The request sends grant_type
urn:ietf:params:oauth:grant-type:token-exchange, the caller's token as subject_token (of
TOKEN_EXCHANGE_SUBJECT_TOKEN_TYPE), requested_token_type
urn:ietf:params:oauth:token-type:access_token, and these keys, with this agent's client
authentication. The answer must be a Bearer access token of at most 16 KiB with a positive
integer expires_in (absent: 60 s); anything else is refused as an unusable answer.
Environment variables lists the settings.
Validation messages (the same from lint, api and the running agent):
| The file | The error |
|---|---|
auth: exchange without exchange |
apis.<name>.exchange: required when auth is exchange (...) |
exchange with another auth |
apis.<name>.exchange: only valid with auth: exchange |
no audience, or a bad one |
apis.<name>.exchange. / must be an audience (...) |
a bad scope |
apis.<name>.exchange. |
a bad resource |
apis.<name>.exchange. |
allow_actorless not a boolean |
apis.<name>.exchange. |
forward_audience with another auth |
apis.<name>.forward_ |
forward_header with none or bearer |
apis.<name>.forward_ |
lint and api add also check each mode against the project's auth policy and runtime (the
compatibility table):
exchange or forward under shared-bearer, forward under jwt without
forward_audience, and either under langgraph-server are errors, and an API with
allow_actorless: true gets a note naming what the agent behind it must set. The app refuses
to start with either where it cannot work, outside APP_ENV=dev.
Operation entries¶
allowed_operations, denied_operations and approval.required_for.operations hold entries
of one shape:
| Key | Type | Meaning |
|---|---|---|
operationId |
string without spaces | The operation's label, as tools pass it in operation_id=. |
path |
path template | /orders/: literal segments and {name} placeholders. |
methods |
list of methods | The methods the entry covers (no "*"); omitted, every method. |
rpc_method |
a JSON-RPC method name | protocol: jsonrpc or a2a only: the method of the JSON-RPC request a POST sends (a letter, then up to 63 letters, digits, _, / or .). Under a2a, write the A2A 1.0 name (CancelTask, not tasks/cancel). |
a2a_operation |
approve | reject |
protocol: a2a only: a message that decides a pending approval of the agent behind the API. With rpc_method, that must be SendMessage or SendStreamingMessage. |
Each entry needs operationId, path or both (on a JSON-RPC API, rpc_method or
a2a_operation will do). An approval key on an entry is refused: gate an operation with
approval.required_for.operations.
A path template starts with / and may end with one /. Each segment holds literal
characters and {name} placeholders only: no query, fragment or whitespace, no empty, . or
.. segment, and none of the characters the client refuses to send (control characters,
whitespace at either end of a segment or next to a dot, ;, a backslash or an encoded
slash), also percent-encoded.
JSON-RPC APIs: protocol¶
protocol: jsonrpc is a JSON-RPC 2.0 API; protocol: a2a is another agent, reached over A2A
1.0 JSON-RPC (an agent built from this template serves it at /a2a/<its name>). Both judge
each POST by the request it sends, which the client reads from the JSON body itself: never from
the tool's operation_id, which the model can influence.
apis:
orders_agent:
description: "Orders agent: reads the caller's orders; cancels one after approval."
protocol: a2a
a2a: {path: /a2a/orders}
base_url_env: ORDERS_AGENT_URL
auth: exchange
exchange: {audience: orders}
allowed_methods: [GET, POST]
allowed_operations:
- {operationId: getAgentCard, methods: [GET], path: /a2a/orders/.well-known/agent-card.json}
- {rpc_method: SendMessage, methods: [POST], path: /a2a/orders}
- {rpc_method: GetTask, methods: [POST], path: /a2a/orders}
approval:
required_for: {operations: [{a2a_operation: approve}]}
approvers: [requester]
What a request is. Under jsonrpc and a2a, a POST must send one JSON-RPC 2.0 request
object: jsonrpc: "2.0", a method name, an id that is a string or an integer, optional
params (an object or an array), and no other member. A batch (a list), a notification (no
id), a body that is not plain JSON (a NaN, a set) or anything else is refused before
anything is sent, as is a body on a GET or HEAD. The body is read as the server reads the JSON
sent. Then:
-
rpc_methodis the request'smethod. Undera2a, an A2A 0.3 name is read as its 1.0 name, so a 0.3 spelling cannot slip past an entry that names the call:A2A 0.3 A2A 1.0 message/send,message/streamSendMessage,SendStreamingMessagetasks/get,tasks/list,tasks/cancel,tasks/resubscribe GetTask,ListTasks,CancelTask,SubscribeToTasktasks/,pushNotificationConfig/ set /get,/list,/deleteCreateTaskPushNotificationConfig,GetTaskPushNotificationConfig,ListTaskPushNotificationConfigs,DeleteTaskPushNotificationConfigagent/getAuthenticatedExtendedCard GetExtendedAgentCard -
a2a_operation, undera2a, is what aSendMessageorSendStreamingMessagedecides. A message part whosedataobject has anapproval_idor adecisionnames an approval (as the called agent reads it). The message isrejectonly when every such part says exactlyreject, andapprovewhen any other does: approve wins. A message naming no approval has noa2a_operation. A message method in another letter case (sendmessage) is read for a decision too, though an A2A server answers it as an unknown method. A message request withoutparams.messageand its list ofpartsis refused.
GET and HEAD have no rpc_method. JSON-RPC APIs allow GET, POST and HEAD only.
Matching. An allow must match every field it pins, rpc_method and a2a_operation
included (compared exactly): {rpc_method: SendMessage} also allows messages that approve
or reject, and the gate separates them. A denial or a gate that pins rpc_method or
a2a_operation covers every call they describe, whatever its path or operation_id:
rpc_method compared ignoring letter case, a2a_operation: approve every message that
approves in any spelling. Its path neither widens nor narrows it, and it also covers the
calls that name its operationId. Every POST names its method, so nothing is left unnamed.
The approve operation must be held. An a2a API whose allow-list may send a message (POST
allowed, and no allowed_operations, or an entry that could match SendMessage or
SendStreamingMessage with an approve) must gate a2a_operation: approve (a rule whose
required_for.methods has POST or "*", or an entry {a2a_operation: approve} in
required_for.operations) or deny it (the same entry in denied_operations). Otherwise this
agent could decide, on its own, the approvals the agent behind the API waits for. The client
also refuses such a message at runtime when nothing holds it. A gate on
{rpc_method: SendMessage} does not count: it leaves SendStreamingMessage out.
A label names its request. A tool's operation_id that names an entry pinning
rpc_method or a2a_operation must name the request sent: a message that approves,
labelled with an entry for GetTask, is refused (operation_id 'getTask' does not match the
request (rpc_method SendMessage); refused). On an http API labels are unchanged.
auth: none is refused with protocol: a2a: an agent's A2A endpoint authenticates its
callers. Outside APP_ENV=dev, an a2a API that sends a credential refuses a plain http
base URL unless the host is loopback, a single-label name or a cluster-internal .svc name.
Validation messages (the same from lint, api and the running agent):
| The file | The error |
|---|---|
another protocol |
apis.<name>.protocol: must be one of http, jsonrpc, a2a (got '<value>') |
| a method other than GET, POST, HEAD | apis.<name>.allowed_ |
protocol: a2a without a2a |
apis.<name>.a2a: required with protocol a2a (...) |
a2a with another protocol |
apis.<name>.a2a: only valid with protocol a2a |
a missing, templated or empty a2a.path |
apis.<name>.a2a. / must be the literal path of one endpoint (...) |
protocol: a2a with auth: none |
apis.<name>.auth: protocol a2a needs a credential (bearer, forward or exchange): ... |
a bad description |
apis.<name>.description: must be text of 1-300 characters without control characters |
rpc_method on an http API |
...rpc_method: only valid with protocol jsonrpc or a2a |
a bad rpc_method |
...rpc_method: must be a JSON-RPC method name (...) |
an A2A 0.3 name under a2a |
...rpc_method: tasks/cancel is the A2A 0.3 name; write CancelTask (...) |
a2a_operation without protocol: a2a |
...a2a_ |
another a2a_operation |
...a2a_ |
a2a_operation with another rpc_method |
...a2a_ |
| approve neither gated nor denied | apis.<name>: protocol a2a allows SendMessage, so this agent could decide approvals at <agent>: gate them (graph-agents-cli api approval <name> --a2a-operations approve --approvers requester) or deny them (graph-agents-cli api deny <name> --a2a-operation approve) |
And at runtime, before anything is sent (ApiPolicyError):
| The request | The error |
|---|---|
| not one JSON-RPC request | <name>: protocol a2a sends one JSON-RPC request per call (a batch or non-request body refused: <why>). |
| a method no entry allows | <name>: POST /a2a/orders (rpc_method DeleteEverything) refused by the API policy: not in allowed_ |
| a denied approve | ... (rpc_method SendMessage, a2a_operation approve) refused by the API policy: denied by denied_ |
| a gated approve | pauses for a person's approval (not an error) |
| a label for another request | <name>: operation_id 'getTask' does not match the request (rpc_method SendMessage); refused. |
approval¶
One rule (a mapping), or a non-empty list of rules of that same shape when different calls need different approvers:
| Key | Value | Meaning |
|---|---|---|
required_for |
{methods, operations}; required |
The calls the rule gates: methods (a list; "*" alone for every method) and/or operations (a non-empty list of operation entries). |
approvers |
list; required | requester (the principal who started the run) and/or role:<name> (any other principal holding the role; a name of 1-256 characters without spaces or commas). |
timeout_s |
integer; default 900 |
30 to 86400 seconds. A pending approval then expires, which rejects the call. |
decide_with |
direct or relayed; default direct |
How the requester decides. direct: with their own credentials, at this agent. relayed: the agents relayers names may deliver the requester's decision from another agent. Needs requester in approvers (role approvers always decide directly). step_up is reserved: "not supported yet". |
relayers |
list; required with relayed, refused otherwise |
The agents that may relay, by actor id (the act.sub of the calling agent's exchanged tokens, usually its client id): 1-256 characters without spaces, commas or control characters. |
approval:
# the requester confirms changes to their orders
- required_for:
operations:
- {operationId: updateOrder, path: "/orders/{order_id}", methods: [PATCH]}
- {operationId: cancelOrder, path: "/orders/{order_id}/cancel", methods: [POST]}
approvers: [requester]
# a second person approves new orders
- required_for:
operations:
- {operationId: createOrder, path: /orders, methods: [POST]}
approvers: ["role:admin"]
timeout_s: 3600
- The first rule wins. A call is gated by the first rule, in file order, whose
required_forcovers it, with that rule's approvers and expiry; a later rule that also covers it does not apply to it.lint,api checkandapi showname the rule (approval[N], from 0) each declared call waits for. - A call that could be either rule's is refused. When an earlier rule covers a call only
because the call leaves out what the rule knows the operation by (no
operation_id, no path), and a later rule with other approvers (or approvers who decide otherwise:decide_with,relayers) also covers it, nothing is sent. Pinpathandmethodsin rules that come before broader ones, and nameoperation_idon every call. - Approval never widens access. A gated call must still pass the rest of the policy.
- Redaction is per call, not in the policy: a tool passes
redact=[...]to name the body and query fields the approver sees masked (KI-052).
A relayed rule, for a call an agent makes after its person approved it at their own agent:
approval:
required_for:
operations:
- {operationId: cancelOrder, path: "/orders/{order_id}/cancel", methods: [POST]}
approvers: [requester]
decide_with: relayed # direct (default) | relayed
relayers: [concierge] # required with relayed: the agents' actor ids
Validation messages (each prefixed with the rule's place, apis.<name>.approval[...]):
| Problem | Message |
|---|---|
decide_with: step_up |
decide_with: step_up is not supported yet (direct or relayed) |
| Another value | decide_with: must be direct or relayed (got '<value>') |
relayed without relayers |
relayers: required with decide_with: relayed (the agents, by actor id, that may deliver the requester's decision) |
relayers without relayed |
relayers: only valid with decide_with: relayed |
| An empty list | relayers: must be a non-empty list of agent (actor) ids |
| A bad id | relayers[N]: '<id>' is not an agent id (1-256 characters without spaces, commas or control characters) |
relayed without requester |
decide_with: relayed needs requester in approvers (role approvers always decide with their own direct credentials, never relayed) |
What approvers see and how a decision is bound to its call (how the approvers decide included): Human approval.
Access shorthands¶
api add --access and api access write the methods themselves into the file, never a name:
--access |
allowed_methods written |
|---|---|
read-only |
[GET, HEAD] |
read-write |
[GET, HEAD, POST, PUT, PATCH, DELETE] |
custom --methods M,... |
exactly those, upper-cased ("*" alone for every method) |
How a call is judged¶
The client judges every call before it is sent, and lint judges every declared call the
same way. A call is refused unless all of these hold:
- Its method is in
allowed_methods(or that list is["*"]). - No denial covers it. A denial names an endpoint and holds whatever label a call gives
it: it covers a call whose path its
pathcovers, whateveroperation_idthe call names, and a call that names itsoperationId, in both cases within itsmethods. Failing closed, it also covers a call that leaves out what the denial knows the operation by (no path against a denial that pins one; no operation id against a denial byoperationIdalone). - When
allowed_operationsis present, an entry matches. An allow needs every field it pins to match: itsoperationId, itspathand itsmethods. A call that does not name a pinned field does not match.
Gates match like denials: an approval.required_for.operations entry gates every call to its
path, and the calls that name its operationId, whatever label they carry. On a JSON-RPC API
the request's rpc_method and a2a_operation, read from its body, are judged too (see
protocol).
- Paths
- Compared after decoding percent-encoded unreserved characters and ignoring one trailing
slash. A
{name}placeholder matches exactly one non-empty segment. Denials and gates also ignore letter case, and a literal segment they name also covers its dot-suffixed spellings (/orders/{id}/cancelcovers/orders/7/cancel.json); an allow never matches that way. - Concrete paths
- A path with
;, a control character, or whitespace at either end of a segment or next to a dot (also percent-encoded) is refused. Pass model input aspath_paramsof a declared template instead: each value is encoded as one segment, and.,..and/are refused. - Requests
- The page-size parameter is capped at
max_page_sizein every spelling and shape of the query. Redirects are never followed. Headers that would change where a request goes or which method the server applies (Host, method overrides,X-Forwarded-*,Forwarded, rewrite and hop-by-hop headers) are dropped, and a_methodquery parameter or top-level body key is refused. - Limits
- Counted last, just before sending; a call over a limit is refused with a reason the
model reads. An answer over
max_response_bytesis discarded as it is read, and a capped answer in a content encoding other than gzip or deflate is refused unread.
A denial or an allow by operationId alone matches only the label a tool passes. Pin path
too, or record openapi: so the api commands pin it for you
(KI-004).
API_CALLS¶
Each tool module declares the calls it makes as one module-level literal list:
API_CALLS = [
{"api": "orders", "method": "GET", "operation_id": "listOrders", "path": "/orders"},
{"api": "orders", "method": "POST", "operation_id": "createOrder", "path": "/orders"},
]
| Key | Required | Meaning |
|---|---|---|
api |
yes | An API name from api-policy.yaml. |
method |
yes | One of the HTTP methods above. |
operation_id |
this and/or path |
The label the tool passes to the client. |
path |
this and/or operation_id |
A path template, as in the policy. |
rpc_method |
every POST to a protocol: jsonrpc or a2a API |
The JSON-RPC method the request sends (an A2A 0.3 name is read as its 1.0 name). Refused on an http API and on GET or HEAD. |
a2a_operation |
a message that decides | approve or reject: an A2A message (rpc_method SendMessage or SendStreamingMessage) that decides a pending approval of the agent behind the API. |
A peer's calls, for example:
API_CALLS = [
{"api": "orders_agent", "method": "POST", "rpc_method": "SendMessage", "path": "/a2a/orders"},
{
"api": "orders_agent",
"method": "POST",
"rpc_method": "SendMessage",
"a2a_operation": "approve",
"path": "/a2a/orders",
},
{"api": "orders_agent", "method": "POST", "rpc_method": "GetTask", "path": "/a2a/orders"},
]
graph-agents-cli lint (and lint --policy-only, api check) reads every *.py under the
agent's tools/ directory, subpackages included, without importing it, and checks:
- every entry against the policy, with the rules above;
- with
openapi:recorded, that the call exists in the spec (byoperationId, or by path and method) and that a declaredoperation_idis the one the spec gives that method and path; a call declared byoperation_idalone is judged with the spec's path; - that
API_CALLSis not changed anywhere else (+=,.append(), a conditional assignment), which lint could not read; - on a JSON-RPC API, that every POST declares its
rpc_method, thatrpc_methodanda2a_operationappear only where they fit, that anoperation_idnames the request declared, and that a message that approves is gated (the client reads all of it from the body; lint judges the declaration the same way); - which declared calls wait for whose approval, and by which rule;
- warnings: an A2A peer without a
description(the model chooses an agent by it), and more than 40 peers.
A refused call is printed with the graph-agents-cli api command that would allow exactly
that call. An invalid policy is a configuration error (exit 3), not a refused call (exit 1):
see Exit codes.
The policy governs the policy client
Only calls made through get_client(...) are judged. A tool with its own HTTP client
bypasses allow-lists, denials, limits and approval gates: keep every outbound call on the
client, and add an egress NetworkPolicy
(KI-005).
-
Declare APIs, evolve the policy with
api, and review changes. -
Choosing gates, deciding calls, and what binds a decision.
-
Every
apisubcommand and flag.