Actions
Define a production call once, with credentials referenced rather than pasted, and let your agent invoke it by name.
An action is an HTTP request that Anlyon makes on your agent’s behalf. You define it once: a method, a URL template, headers, an optional body template, and a JSON Schema describing the input it accepts. Your agent then invokes it by name with input, and never learns the rest.
Defining one
Define actions from an operator key, not the key your agent runs with. Creating an action needs actions:write, and putting a secret in the vault needs secrets:write. Neither belongs in a process a model is driving.
import { Client } from '@anlyonhq/sdk';
const ops = new Client({ apiKey: process.env.ANLYON_OPERATOR_KEY! });
await ops.secrets.put('STRIPE_KEY', {
value: process.env.STRIPE_KEY!,
allowedHosts: ['api.stripe.com'], // the only host this key may ever be sent to
allowedPlacements: ['header'],
});
await ops.actions.create({
name: 'refund-order',
description: 'Refund a Stripe charge, in full or in part.',
method: 'POST',
urlTemplate: 'https://api.stripe.com/v1/refunds',
headers: {
Authorization: 'Bearer {{secret:STRIPE_KEY}}',
'Content-Type': 'application/x-www-form-urlencoded', // Stripe's v1 API reads form bodies
},
inputSchema: {
type: 'object',
properties: {
charge: { type: 'string', pattern: '^ch_' },
amount: { type: 'integer' },
},
required: ['charge', 'amount'],
},
requiresApproval: true,
});import os
from anlyon import Client
ops = Client(api_key=os.environ["ANLYON_OPERATOR_KEY"])
ops.secrets.put(
"STRIPE_KEY",
value=os.environ["STRIPE_KEY"],
allowed_hosts=["api.stripe.com"], # the only host this key may ever be sent to
allowed_placements=["header"],
)
ops.actions.create(
name="refund-order",
description="Refund a Stripe charge, in full or in part.",
method="POST",
url_template="https://api.stripe.com/v1/refunds",
headers={
"Authorization": "Bearer {{secret:STRIPE_KEY}}",
"Content-Type": "application/x-www-form-urlencoded", # Stripe's v1 API reads form bodies
},
input_schema={
"type": "object",
"properties": {
"charge": {"type": "string", "pattern": "^ch_"},
"amount": {"type": "integer"},
},
"required": ["charge", "amount"],
},
requires_approval=True,
)Templates
urlTemplate, headers and bodyTemplate accept two kinds of placeholder:
| Placeholder | Resolves to |
|---|---|
{{input.field}} |
A field from the input the agent passed, after it has been validated against inputSchema |
{{input.field:path}} |
In urlTemplate’s path only: an input that may span several path segments |
{{secret:NAME}} |
A vaulted secret, decrypted at dispatch and never returned to the caller |
In urlTemplate, an input is percent-encoded into the one path segment or query value it occupies, so it cannot add /, ?, # or .. and change which endpoint is called: /v1/customers/{{input.id}} with ../../v1/refunds requests /v1/customers/..%2F..%2Fv1%2Frefunds, not /v1/refunds. When an input really is a multi-segment path, write {{input.field:path}}: each segment is encoded and empty, . and .. segments are refused. An input in the host (allowed only for actions that reference no secret) must be a hostname with an optional :port. Headers are not URL-encoded. An input value is always data: a value that looks like {{secret:NAME}} is sent as that text, never resolved.
Request encoding
For methods that carry a body, the body is the rendered bodyTemplate, or the whole input when there is no template. It is sent as JSON with Content-Type: application/json unless the action’s own headers say otherwise.
Declare Content-Type: application/x-www-form-urlencoded in headers and the body is form-encoded instead, which many older APIs require. Nested values use bracket notation (metadata[order]=42, expand[0]=charge, items[0][price]=p_1), and null values are left out rather than sent as the text null. The header is part of the action definition, so the encoding is versioned and captured in an approval snapshot with the rest of the request.
inputSchema is a JSON Schema subset: type, properties, required, additionalProperties, enum, pattern and items. Input that does not validate is refused before anything is sent, which is the cheapest place to catch a model that invented an argument.
Invoking one
This is the only part your agent needs.
const anlyon = new Client({ apiKey: process.env.ANLYON_AGENT_KEY! });
const { data } = await anlyon.actions.invoke('refund-order', {
charge: 'ch_3P9x',
amount: 12000,
});
if (data!.pendingApproval) {
console.log('Parked for a human:', data!.approvalId);
} else {
console.log('Upstream replied', data!.responseStatus);
}anlyon = Client(api_key=os.environ["ANLYON_AGENT_KEY"])
result = anlyon.actions.invoke("refund-order", {"charge": "ch_3P9x", "amount": 12000})
if result.data["pendingApproval"]:
print("Parked for a human:", result.data["approvalId"])
else:
print("Upstream replied", result.data["responseStatus"])The agent key needs actions:invoke. It does not need secrets:read, which is checked on the key that defines the action. It does not need actions:write. A key that can rewrite the action it is about to call is a key that can move where your credential goes.
What comes back
| Status | Meaning |
|---|---|
200 |
The call ran inline. responseStatus is what the upstream API replied. |
202 |
The call is parked behind a human decision. pendingApproval is true and approvalId names the approval. |
Poll a parked invocation with actions.invocation(invocationId), or wait on the approval itself. The invocation’s status moves through pending_approval, running, and then one of succeeded, failed, unknown, denied or expired. Those last few are not interchangeable. A governed action also returns a receipt grade. See Receipts and grades.
Versions
Editing an action’s request, approval requirement, adapter or declarations appends an immutable version. A rename, a description change, or an enable or disable does not. Nothing updates a version in place. An agent version can pin the exact action version ids it shipped with, and the invoke path runs the pinned definition, including its approval requirement, because the gate is part of the definition.
Pinning is per invocation and opt-in: pass a runId whose run names an agent, and if that agent’s published version pins this action, the pinned definition runs. Without a runId, or when the agent pins nothing for this action, the current definition runs. The response reports which version actually executed as actionVersionId.
An agent version that pins nothing rolls back to a pointer and nothing else. The mechanism exists. Using it is a discipline it cannot enforce on you. See Immutable versions and rollback.
Approval gates
Setting requiresApproval: true on the definition sends every invocation of it through an approval. Only a policy that names the action can auto-approve it. Changing that flag needs actions:govern, which is not granted by default, so a key that can edit an action still cannot ungate it.
An explicit approval policy governs on top of the flag. Policies are evaluated on every invocation and can require approval for a call the flag would have let through, and a policy that does not name the action cannot remove the flag’s gate. See Approvals and policies.
Governed actions: impact, verify and governed
Any action may carry three more fields. An action that carries one of them is a governed action. Each invocation then creates an effect. The effect is reserved against shared limits, its approval is bound to the exact request, and it returns a receipt grade.
| Field | What it declares |
|---|---|
impact |
How much one invocation changes: { dimension, unit, amount, bound }. |
verify |
A read-back that confirms the write: { url, status, match }. |
governed: true |
Governs an action that declares neither of the other two. Its 2xx then grades acknowledged. |
impact
dimensionismoney,resource_mutations, or a unit you name, such asemails,messagesorrows. A unit you name matches^[a-z][a-z0-9_]{0,31}$.unitis the currency formoney, for exampleusd. For every other dimension it is the dimension name and may be left out.amountis an expression over the validated input. It is an integer,input.<path>orcount(input.<path>), with one optional multiplication by an integer. The result must be a non-negative integer.boundisexactorupper_bound.
An amount that reads an optional input the caller left out gives no quantity. That effect is unbounded for the dimension. A limit that applies refuses it with impact_unbounded. With no applicable limit it runs.
verify
urlis aGETon the action’s own host. It may reference{{input.field}},{{response.field}}and{{secret:NAME}}.statusis the HTTP status the read-back must answer with.matchis a list of up to 10 equality checks, each{ path, equals }.pathis a dotted JSON path such asdata.status.equalsis a literal, or exactly one{{input.field}}or{{response.field}}.
Anlyon issues the read-back after the provider accepts the write, with the action’s own headers. A match grades the effect confirmed. Without a match, a 2xx grades acknowledged.
Rules a governed action must meet
- The URL must be
https, and the host is fixed by the definition. An input may not choose the host. A definition whose host comes from an input is refused when it is saved. - The verify URL must be on the action’s own host.
- An input that spells a secret reference is refused.
- An adapter action is already governed and accepts none of the three fields.
await ops.actions.declare('send-email', {
impact: { dimension: 'emails', amount: 'count(input.to)', bound: 'exact' },
verify: {
url: 'https://api.example.com/v1/messages/{{input.messageId}}',
status: 200,
match: [{ path: 'subject', equals: '{{input.subject}}' }],
},
});ops.actions.declare(
"send-email",
impact={"dimension": "emails", "amount": "count(input.to)", "bound": "exact"},
verify={
"url": "https://api.example.com/v1/messages/{{input.messageId}}",
"status": 200,
"match": [{"path": "subject", "equals": "{{input.subject}}"}],
},
)actions.create() and actions.update() take the same three fields. The action then reads back with its declarations:
{
"governed": true,
"adapter": null,
"impact": { "dimension": "emails", "unit": "emails", "amount": "count(input.to)", "bound": "exact" },
"verify": {
"url": "https://api.example.com/v1/messages/{{input.messageId}}",
"status": 200,
"match": [{ "path": "subject", "equals": "{{input.subject}}" }]
}
}
Changing what an action declares
- A field you leave out keeps what the action declares.
impact: nullorverify: nullremoves that declaration.governed: falseremoves every declaration.- Setting
impactreplaces the whole impact. The same holds forverify. - Each change appends a version. An agent version that pins the action pins its declarations too, and a rollback restores them with the definition.
Declaring on an action that has no declaration needs actions:write. Changing or removing what a governed action already declares also needs actions:govern. No key receives that scope by default. A key that can edit an action therefore cannot lower its declared amount or take it out from under a limit.
See Govern any HTTP API for a worked example.
Importing from OpenAPI
actions.importFromOpenApi() reads an OpenAPI document and creates one action per operation, optionally attaching a vaulted secret as a header on all of them. It turns an internal service you already describe in a spec into a set of actions an agent can invoke by name.
How it behaves
- A governed action has an adapter or a declaration. It creates an effect, returns a grade and counts against an impact limit. An action with neither keeps the template executor and records the HTTP response.
- Anlyon governs the calls routed through an action. A tool your own code calls directly runs in your process with your credential. So does a function behind the local
approvals.gate()wrapper. - A declared action sends its write once. It sends no idempotency key to the provider, and a replay is refused before anything is sent. A lost response is recovered by the read-back or by an operator.
- The grade of a declared action follows its
verifyrule. A2xxwith a matching read-back gradesconfirmed. A2xxwith noverifyrule gradesacknowledged. A verify URL that reads{{response.*}}needs the provider response, so after a lost response an operator resolves that effect. - The approval of a declared action binds the exact request. The preview shows the request as it will be sent. Provider state is outside the binding.
- The amount is your expression. For an API you declare, you write what the action counts, and Anlyon enforces it before dispatch. An
upper_boundset too low is an error in the definition. - A verify rule matches whatever the read-back returns. A resource that already existed can match. Write a rule that distinguishes this write, for example a match on a value this request set.
- Rolling back restores definitions for later invocations. A request that was already dispatched stays as it is.
- Any workspace member can decide an approval.

