---
title: "Actions"
description: "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.

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

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

**A secret-bearing action has a fixed destination**

If an action references a secret, its scheme and host must be literal. You cannot interpolate input into the host of a URL that carries a credential, because that would let the invoker choose who receives your Stripe key. A secret in the URL's authority is refused outright: it would be exposed to DNS resolution before the request was even made.

## Invoking one

This is the only part your agent needs.

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

```python Python
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](/execution/outcomes).

## 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](/trust-control/promotion).

## 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](/trust-control/approvals).

**A new action name needs its own gate**

Deleting an action archives it, and its versions and invocations are kept. Deleting an action that requires approval needs `actions:govern`. A key with `actions:write` can create a new action, and a policy that matches by action name applies to that name. Give a new action its own gate or policy.

## 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](/execution/impact-limits), its approval is [bound to the exact request](/execution/previews), and it returns a [receipt grade](/execution/outcomes).

| 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`**

- `dimension` is `money`, `resource_mutations`, or a unit you name, such as `emails`, `messages` or `rows`. A unit you name matches `^[a-z][a-z0-9_]{0,31}$`.
- `unit` is the currency for `money`, for example `usd`. For every other dimension it is the dimension name and may be left out.
- `amount` is an expression over the validated input. It is an integer, `input.<path>` or `count(input.<path>)`, with one optional multiplication by an integer. The result must be a non-negative integer.
- `bound` is `exact` or `upper_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`**

- `url` is a `GET` on the action's own host. It may reference `{{input.field}}`, `{{response.field}}` and `{{secret:NAME}}`.
- `status` is the HTTP status the read-back must answer with.
- `match` is a list of up to 10 equality checks, each `{ path, equals }`. `path` is a dotted JSON path such as `data.status`. `equals` is 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.

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

```python Python
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:


```json
{
  "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: null` or `verify: null` removes that declaration.
- `governed: false` removes every declaration.
- Setting `impact` replaces the whole impact. The same holds for `verify`.
- 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](/guides/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 `verify` rule.** A `2xx` with a matching read-back grades `confirmed`. A `2xx` with no `verify` rule grades `acknowledged`. 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_bound` set 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.**

## Where to go next

**[Secrets](/execution/secrets)**

How `{{secret:NAME}}` is resolved, and what constrains where it may go.

**[Idempotency and retries](/execution/idempotency)**

Invoking the same operation twice, safely.

**[Approvals and policies](/trust-control/approvals)**

Deciding which invocations need a person.

**[Receipts and grades](/execution/outcomes)**

What is recorded, the seven grades, and how to read `unknown`.
