Govern any HTTP API
Define an action on an HTTPS API Anlyon has never seen, declare what one call changes, put it under a shared limit, bind the approval to the exact request and read the receipt grade.
Anlyon ships a small number of provider adapters. Every other API is reached by a declared action: an HTTP action that says what one call changes and how to read the result back. A declared action gets three things. Its impact is reserved against a shared limit before dispatch. Every approval is bound to a digest of the exact request. Every call returns a receipt with a grade.
This guide uses a fictional mailer at https://api.example.com. Nothing here is specific to email. The unit could be rows, tickets or anything else you count.
Read How it behaves before you rely on one.
1. Define the action and declare its impact
Two keys are in play. An operator key defines the action and stores the credential. An agent key invokes.
The definition is an ordinary HTTP template plus two declarations, impact and verify.
import { Client } from '@anlyonhq/sdk';
const ops = new Client({ apiKey: process.env.ANLYON_OPERATOR_KEY! });
await ops.secrets.put('MAILER_TOKEN', {
value: process.env.MAILER_TOKEN!,
allowedHosts: ['api.example.com'],
allowedPlacements: ['header'],
});
const { data: action } = await ops.actions.create({
name: 'send-email',
method: 'PUT',
urlTemplate: 'https://api.example.com/v1/emails/{{input.emailId}}',
headers: { Authorization: 'Bearer {{secret:MAILER_TOKEN}}' },
bodyTemplate: { to: '{{input.to}}', subject: '{{input.subject}}' },
inputSchema: {
type: 'object',
required: ['emailId', 'to', 'subject'],
properties: {
emailId: { type: 'string', pattern: '^[a-z0-9-]{1,40}$' },
to: { type: 'array', items: { type: 'string' } },
subject: { type: 'string' },
},
},
impact: { dimension: 'emails', amount: 'count(input.to)', bound: 'exact' },
verify: {
url: 'https://api.example.com/v1/emails/{{input.emailId}}',
status: 200,
match: [{ path: 'subject', equals: '{{input.subject}}' }],
},
});import os
from anlyon import Client
ops = Client(api_key=os.environ["ANLYON_OPERATOR_KEY"])
ops.secrets.put(
"MAILER_TOKEN",
value=os.environ["MAILER_TOKEN"],
allowed_hosts=["api.example.com"],
allowed_placements=["header"],
)
action = ops.actions.create(
name="send-email",
method="PUT",
url_template="https://api.example.com/v1/emails/{{input.emailId}}",
headers={"Authorization": "Bearer {{secret:MAILER_TOKEN}}"},
body_template={"to": "{{input.to}}", "subject": "{{input.subject}}"},
input_schema={
"type": "object",
"required": ["emailId", "to", "subject"],
"properties": {
"emailId": {"type": "string", "pattern": "^[a-z0-9-]{1,40}$"},
"to": {"type": "array", "items": {"type": "string"}},
"subject": {"type": "string"},
},
},
impact={"dimension": "emails", "amount": "count(input.to)", "bound": "exact"},
verify={
"url": "https://api.example.com/v1/emails/{{input.emailId}}",
"status": 200,
"match": [{"path": "subject", "equals": "{{input.subject}}"}],
},
).datacurl -s -X POST https://api.anlyon.com/api/v2/actions \
-H "Authorization: Bearer $ANLYON_OPERATOR_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "send-email",
"method": "PUT",
"urlTemplate": "https://api.example.com/v1/emails/{{input.emailId}}",
"headers": { "Authorization": "Bearer {{secret:MAILER_TOKEN}}" },
"bodyTemplate": { "to": "{{input.to}}", "subject": "{{input.subject}}" },
"inputSchema": {
"type": "object",
"required": ["emailId", "to", "subject"],
"properties": {
"emailId": { "type": "string", "pattern": "^[a-z0-9-]{1,40}$" },
"to": { "type": "array", "items": { "type": "string" } },
"subject": { "type": "string" }
}
},
"impact": { "dimension": "emails", "amount": "count(input.to)", "bound": "exact" },
"verify": {
"url": "https://api.example.com/v1/emails/{{input.emailId}}",
"status": 200,
"match": [{ "path": "subject", "equals": "{{input.subject}}" }]
}
}'The stored action comes back with governed: true. The server fills in the unit of a non-money impact.
{
"governed": true,
"adapter": null,
"impact": { "dimension": "emails", "unit": "emails", "amount": "count(input.to)", "bound": "exact" },
"verify": { "status": 200, "match": [{ "path": "subject", "equals": "{{input.subject}}" }] }
}
The block shows the declaration fields. The full response also carries the stored verify.url and the rest of the definition.
The impact declaration
| Field | Meaning |
|---|---|
dimension |
money, resource_mutations, or a unit you name. A unit is lowercase letters, digits and underscores, starts with a letter, and is at most 32 characters. |
unit |
Required for money: one lowercase ISO currency such as usd. For every other dimension the unit is the dimension name and you may leave it out. |
amount |
An expression over the validated input. See the grammar below. |
bound |
exact or upper_bound. |
The amount grammar is small on purpose. An amount is one of three terms, with one optional multiplication by an integer:
| Expression | Value |
|---|---|
1 |
An integer literal. Every call counts the same. |
input.amount |
The value at that input path. It must be a non-negative integer. |
count(input.to) |
The length of the array at that input path. |
count(input.to) * 2 |
Any of the three, times an integer literal. |
Nothing else parses. An expression such as len(input.to) is refused when you save the action.
The amount is evaluated at admission, before dispatch, from the input after schema validation. The agent never states the quantity.
bound says how to read the number. exact means the call changes exactly that much. upper_bound means the call changes at most that much. Both reserve the full amount against a limit. You write what the action counts, and Anlyon enforces it before dispatch, so set an upper_bound at the most one call can change.
An optional input yields no quantity. If the amount reads an input the caller left out, the effect has no quantity for that dimension. It is unbounded, never zero. An applicable limit refuses it with impact_unbounded. With no applicable limit it runs. Make an input required in the schema when the amount depends on it. See When the limit is hit.
The verify rule
verify is a read-back. After the provider accepts the write, Anlyon issues one GET and compares the answer with your rule.
- The request is a
GETon the action’s own host. A verify URL on another host is refused when you save the action. - The URL may use
{{input.x}}and{{response.x}}.{{response.x}}reads the provider’s answer to the write, for an API that chooses the id itself. statusis the HTTP status the read-back must answer with.matchis a list of at most 10 equality checks. Each has a dotted JSONpathinto the read-back body and anequalsvalue.equalsis a string, number, boolean ornull, or exactly one{{input.x}}or{{response.x}}.- The read-back is sent through the same transport with the action’s own headers, so it carries the same credential reference.
A match grades the receipt confirmed. A 2xx with no verify rule, or with a read-back that did not match, grades acknowledged. A read-back never changes an outcome and is never a second write.
A verify URL that uses {{response.x}} cannot be rebuilt when the response is lost. The preview says so in its limitations. See When the outcome is unknown.
governed: true
An action is governed when it carries impact, verify, or governed: true. Set governed: true alone when you want the bound approval and the receipt grade and have nothing to count or read back.
A governed action needs a fixed https host. A URL template whose host contains {{input.x}} is refused when the declarations are saved. An input may not choose where a governed request is sent.
An action with no declaration and no adapter is not governed. It keeps the template executor. It creates no effect, draws from no limit and returns no grade.
2. Create a limit in that unit
A limit names the same unit. Every agent, session and key in the environment draws from it.
const { data: limit } = await ops.impactLimits.create({
name: 'daily emails',
dimension: 'emails',
period: 'day',
amount: 5,
});limit = ops.impact_limits.create(name="daily emails", dimension="emails", period="day", amount=5).datacurl -s -X POST https://api.anlyon.com/api/v2/impact-limits \
-H "Authorization: Bearer $ANLYON_OPERATOR_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "daily emails", "dimension": "emails", "period": "day", "amount": 5 }'{ "dimension": "emails", "unit": "emails", "currency": null, "limit": 5, "available": 5 }
Creating a limit needs impact-limits:write. No key receives that scope by default. A period is day, month or total. A day is the UTC day. Add actionName to scope a limit to one action, or resourcePrefix to scope it to a path. The resource key of a declared action is http:<host><path>.
3. Invoke
The agent names the action and passes input. It holds no URL, no header and no credential.
const agent = new Client({ apiKey: process.env.ANLYON_AGENT_KEY! });
const { data } = await agent.actions.invoke('send-email', {
emailId: 'welcome-1',
to: ['a@example.com'],
subject: 'Welcome',
});
console.log(data!.status, data!.grade, data!.effectId);agent = Client(api_key=os.environ["ANLYON_AGENT_KEY"])
result = agent.actions.invoke(
"send-email",
{"emailId": "welcome-1", "to": ["a@example.com"], "subject": "Welcome"},
)
print(result.data["status"], result.data["grade"], result.data["effectId"])curl -s -X POST https://api.anlyon.com/api/v2/actions/send-email/invoke \
-H "Authorization: Bearer $ANLYON_AGENT_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: welcome-1" \
-d '{ "input": { "emailId": "welcome-1", "to": ["a@example.com"], "subject": "Welcome" } }'{ "status": "succeeded", "grade": "confirmed" }
The destination receives two requests for this call, in this order: the PUT write, then the GET read-back.
4. See the bound approval
Every governed invocation has a preview: the normalized request, the resolved target, the action and adapter versions, the preconditions and an expiry. Anlyon hashes those into a bindingDigest. An approval is stored with that digest. Dispatch recomputes it and refuses on any difference.
You can create the preview yourself and show it to a reviewer before invoking.
const input = { emailId: 'welcome-1', to: ['a@example.com'], subject: 'Welcome' };
const { data: preview } = await agent.actions.preview('send-email', input);
console.log(preview!.id, preview!.bindingDigest, preview!.expiresAt);
for (const block of preview!.blocks) console.log(block.type, block.title);
await agent.actions.invoke('send-email', input, { previewId: preview!.id });payload = {"emailId": "welcome-1", "to": ["a@example.com"], "subject": "Welcome"}
preview = agent.actions.preview("send-email", payload).data
print(preview["id"], preview["bindingDigest"], preview["expiresAt"])
for block in preview["blocks"]:
print(block["type"], block["title"])
agent.actions.invoke("send-email", payload, preview_id=preview["id"])curl -s -X POST https://api.anlyon.com/api/v2/actions/send-email/previews \
-H "Authorization: Bearer $ANLYON_AGENT_KEY" \
-H "Content-Type: application/json" \
-d '{ "input": { "emailId": "welcome-1", "to": ["a@example.com"], "subject": "Welcome" } }'
curl -s -X POST https://api.anlyon.com/api/v2/actions/send-email/invoke \
-H "Authorization: Bearer $ANLYON_AGENT_KEY" \
-H "Content-Type: application/json" \
-d '{ "input": { "emailId": "welcome-1", "to": ["a@example.com"], "subject": "Welcome" }, "previewId": "prv_01JABCDEF" }'{
"adapter": { "type": "http.declared" },
"impact": [{ "dimension": "emails", "amount": 1, "bound": "exact" }],
"request": { "headers": { "Authorization": "Bearer {{secret:MAILER_TOKEN}}" } },
"blocks": [{ "type": "impact", "title": "Expected impact: 1 emails" }]
}
Three things to notice.
- The stored request keeps the secret as a reference. No preview, effect or evidence holds the credential. The transport resolves the reference when it sends.
- Creating this preview sent nothing to
api.example.com. Anlyon has no way to read an unknown API before the write, so the preview is the request itself. - The preview’s
limitationsblock lists what a declared action does not have. Show it to the reviewer.
What a previewId does. The invocation must match the reviewed request exactly. A different recipient list, subject or id returns HTTP 409 PREVIEW_MISMATCH at admission and creates no effect. A preview is used once: a second invocation with the same id returns PREVIEW_ALREADY_USED. An expired one returns PREVIEW_EXPIRED. A preview you create lasts one hour unless you pass ttlSeconds, from 60 seconds to 7 days.
Without a previewId the invocation builds its own preview, and that preview lasts as long as the approval window. The binding is the same either way.
For an action with requiresApproval: true, the invocation parks and returns the approval.
{ "pendingApproval": true, "grade": "pending" }
The effect waits at stage: "awaiting_approval". An operator approves it, and approving is what dispatches.
const { data: decided } = await ops.approvals.approve(data!.approvalId!, { note: 'Checked the recipient.' });
console.log(decided!.invocation?.status);
const { data: effect } = await ops.actions.effect(data!.effectId!);
console.log(effect!.grade, effect!.bindingDigest);decided = ops.approvals.approve(result.data["approvalId"], note="Checked the recipient.").data
print(decided["invocation"]["status"])
effect = ops.actions.effect(result.data["effectId"]).data
print(effect["grade"], effect["bindingDigest"])curl -s -X POST https://api.anlyon.com/api/v2/approvals/apr_01JABCDEF/approve \
-H "Authorization: Bearer $ANLYON_OPERATOR_KEY" \
-H "Content-Type: application/json" \
-d '{ "note": "Checked the recipient." }'
curl -s https://api.anlyon.com/api/v2/actions/effects/eff_01JABCDEF \
-H "Authorization: Bearer $ANLYON_OPERATOR_KEY"After the approval, the invocation comes back succeeded and the effect grades confirmed. The digest stored on the approval equals the effect’s bindingDigest, and the destination receives one write. Deciding needs approvals:decide, and a key can never decide an approval it requested. See When approval expires or changes.
5. Read the grade
The invoke response carries the grade. The effect carries the evidence behind it.
const { data: effect } = await agent.actions.effect(data!.effectId!);
console.log(effect!.grade, effect!.verification);
for (const item of effect!.evidence) console.log(item.source, item.summary);
const { data: unread } = await ops.actions.effects({ grade: 'acknowledged', action: 'send-email' });effect = agent.actions.effect(result.data["effectId"]).data
print(effect["grade"], effect["verification"])
for item in effect["evidence"]:
print(item["source"], item["summary"])
unread = ops.actions.effects(grade="acknowledged", action="send-email").datacurl -s https://api.anlyon.com/api/v2/actions/effects/eff_01JABCDEF \
-H "Authorization: Bearer $ANLYON_AGENT_KEY"
curl -s "https://api.anlyon.com/api/v2/actions/effects?grade=acknowledged&action=send-email" \
-H "Authorization: Bearer $ANLYON_OPERATOR_KEY"{
"stage": "settled",
"outcome": "succeeded",
"grade": "confirmed",
"verification": "provider",
"adapter": { "type": "http.declared", "version": "1" },
"attempts": [{ "kind": "dispatch", "status": "completed" }],
"evidence": [
{ "source": "preview" },
{ "source": "system" },
{ "source": "provider_response" },
{ "source": "provider_lookup" }
]
}
The last evidence entry is the read-back. Its summary says the read-back matched the declared verify rule.
The same action with no verify rule answers differently.
{ "outcome": "succeeded", "grade": "acknowledged", "providerReference": null }
| Grade | Meaning |
|---|---|
confirmed |
The outcome is succeeded and a read-back matched. |
acknowledged |
The provider accepted the request with a 2xx. No read-back has matched. An operator’s manual succeeded also grades here. |
unknown |
No usable response. Anlyon does not send it again. It stays unknown until a read-back or an operator settles it. The allowance stays held. |
failed |
The provider rejected the request, or the request provably never left Anlyon. |
refused |
Anlyon refused at dispatch, for example on an exhausted limit. |
denied |
The approval was denied. |
pending |
No receipt yet. The effect is waiting for approval or is being dispatched. |
Changing the declarations
declare sends a PATCH that carries the declarations and nothing else. update takes the same three fields beside the rest of the definition.
// Replace the impact. The verify rule is not named, so it is kept.
await ops.actions.declare('send-email', {
impact: { dimension: 'emails', amount: 'count(input.to) * 2', bound: 'upper_bound' },
});
await ops.actions.declare('send-email', { verify: null }); // remove the read-back
await ops.actions.declare('send-email', { governed: false }); // remove every declaration# Replace the impact. The verify rule is not named, so it is kept.
ops.actions.declare(
"send-email",
impact={"dimension": "emails", "amount": "count(input.to) * 2", "bound": "upper_bound"},
)
ops.actions.declare("send-email", verify=None) # remove the read-back
ops.actions.declare("send-email", governed=False) # remove every declarationcurl -s -X PATCH https://api.anlyon.com/api/v2/actions/send-email \
-H "Authorization: Bearer $ANLYON_OPERATOR_KEY" \
-H "Content-Type: application/json" \
-d '{ "impact": { "dimension": "emails", "amount": "count(input.to) * 2", "bound": "upper_bound" } }'
curl -s -X PATCH https://api.anlyon.com/api/v2/actions/send-email \
-H "Authorization: Bearer $ANLYON_OPERATOR_KEY" \
-H "Content-Type: application/json" \
-d '{ "verify": null }'
curl -s -X PATCH https://api.anlyon.com/api/v2/actions/send-email \
-H "Authorization: Bearer $ANLYON_OPERATOR_KEY" \
-H "Content-Type: application/json" \
-d '{ "governed": false }'The rules:
nullremoves that one declaration.- Omission keeps what the action already declares.
governed: falseremoves all declarations. Sending it beside animpactor averifyis refused.- Setting an impact replaces the whole impact. The same holds for the verify rule.
- Each change is a new immutable action version. A pin or a rollback restores a version’s declarations with its definition.
After governed: false the action answers:
{ "governed": false, "impact": null, "verify": null }
Scopes. Declaring on an action that declares nothing needs actions:write. Changing or removing what a governed action already declares also needs actions:govern, which is not granted by default. Without it the request is refused with this message:
Changing what a governed action declares needs the "actions:govern" scope, which is not granted by default.
The agent a limit constrains should not be able to loosen the declaration that feeds it. A change to the description or another field that leaves the declarations alone does not need actions:govern.
From the CLI
anlyon actions declare sets and removes the same declarations. A flag you leave out keeps what the action declares.
# One call sends count(input.to) emails.
anlyon actions declare send-email --dimension emails --amount 'count(input.to)' --bound exact --yes
# Money needs its currency.
anlyon actions declare refund-payment --dimension money --unit usd --amount input.amount --bound upper_bound --yes
# Read the write back after dispatch.
anlyon actions declare send-email \
--verify-url 'https://api.example.com/v1/emails/{{input.emailId}}' --verify-status 200 \
--verify-match 'subject={{input.subject}}' --yes
anlyon actions declare send-email --governed --yes # governed, nothing counted
anlyon actions declare send-email --no-verify --yes # remove the read-back
anlyon actions declare send-email --no-impact --yes # remove the impact
anlyon actions declare send-email --ungoverned --yes # remove every declaration
anlyon actions get send-email # shows governed, impact and verify
anlyon effects list --action send-email --grade refused
--verify-match may be repeated, up to 10 times. A value that reads as a number, true, false or null is sent as that. Quote it as JSON to send a string. Without --yes a non-interactive run sends nothing. No browser login grants actions:govern, so use an operator API key to change what a governed action declares.
How it behaves
- Governed means an adapter or a declaration. A declaration puts the action under a limit. Keep the credential in the vault so the action is the path to the API.
- A limit belongs to one environment. The same limit name in two environments is two limits.
- The write is sent once. A declared action sends no idempotency key to the provider.
- The approval binds the exact request. Provider state between review and dispatch is outside the binding.
- A lost response is recovered by the read-back or by an operator. A replay is refused before anything is sent.
- The amount is what you wrote. For an API you declare, you write what the action counts, and Anlyon enforces it before dispatch.
confirmedmeans your own verify rule matched. A read-back on a resource that already existed can match. Write a rule that distinguishes this write, for example a match on a value this request set.- A receipt is a database record with provider evidence.
- Any workspace member can decide an approval.
When the limit is hit
What the caller sees when a shared limit refuses a call
When approval expires or changes
Preview expiry, a changed request and a changed action
When the outcome is unknown
A lost response, the held allowance and how it settles
Impact limits
How capacity is reserved, settled, released and held

