---
title: "Govern any HTTP API"
description: "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](/execution/impact-limits) 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](#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`.

```typescript TypeScript
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}}' }],
  },
});
```

```python Python
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}}"}],
    },
).data
```

```bash curl
curl -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.

```json
{
  "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](/execution/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 `GET` on 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.
- `status` is the HTTP status the read-back must answer with.
- `match` is a list of at most 10 equality checks. Each has a dotted JSON `path` into the read-back body and an `equals` value. `equals` is a string, number, boolean or `null`, 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](/execution/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.

```typescript TypeScript
const { data: limit } = await ops.impactLimits.create({
  name: 'daily emails',
  dimension: 'emails',
  period: 'day',
  amount: 5,
});
```

```python Python
limit = ops.impact_limits.create(name="daily emails", dimension="emails", period="day", amount=5).data
```

```bash curl
curl -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 }'
```

```json
{ "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.

```typescript TypeScript
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);
```

```python Python
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"])
```

```bash curl
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" } }'
```

```json
{ "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.

```typescript TypeScript
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 });
```

```python Python
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"])
```

```bash curl
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" }'
```

```json
{
  "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 `limitations` block 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.

```json
{ "pendingApproval": true, "grade": "pending" }
```

The effect waits at `stage: "awaiting_approval"`. An operator approves it, and approving is what dispatches.

```typescript TypeScript
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);
```

```python Python
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"])
```

```bash curl
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](/execution/when-approval-expires-or-changes).

## 5. Read the grade

The invoke response carries the grade. The effect carries the evidence behind it.

```typescript TypeScript
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' });
```

```python Python
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").data
```

```bash curl
curl -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"
```

```json
{
  "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.

```json
{ "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.

```typescript TypeScript
// 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
```

```python Python
# 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 declaration
```

```bash curl
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 '{ "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:

- **`null` removes** that one declaration.
- **Omission keeps** what the action already declares.
- **`governed: false` removes all** declarations. Sending it beside an `impact` or a `verify` is 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:

```json
{ "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:

```text
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.

```bash
# 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.
- **`confirmed` means 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](/execution/when-the-limit-is-hit)**

What the caller sees when a shared limit refuses a call

**[When approval expires or changes](/execution/when-approval-expires-or-changes)**

Preview expiry, a changed request and a changed action

**[When the outcome is unknown](/execution/when-the-outcome-is-unknown)**

A lost response, the held allowance and how it settles

**[Impact limits](/execution/impact-limits)**

How capacity is reserved, settled, released and held
