Skip to content
Anlyon
Esc
↑↓navigate↵open⌘Jpreview
On this page

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

  • 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.
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: 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 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

Was this page helpful?