# Action fields

## Contents
- Request fields
- Placeholders
- Input schema subset
- Request encoding
- impact
- verify
- Adapter actions
- Changing declarations, versions, deletion
- Scopes

## Request fields

| Field | Notes |
| --- | --- |
| `name` | `^[a-zA-Z0-9_-]+$`, 1 to 100 characters, unique per environment (archived names included) |
| `description` | Up to 1,000 characters. The model, MCP tool lists and approvers read it: say what the call changes and any limitation |
| `method` | `GET`, `POST` (default), `PUT`, `PATCH`, `DELETE` |
| `urlTemplate` | Up to 2,048 characters |
| `headers` | String values. Headers are not URL-encoded |
| `bodyTemplate` | Object. Without one, the whole input is the body |
| `inputSchema` | JSON Schema subset, below |
| `requiresApproval` | Every call parks for a person. Changing it later needs `actions:govern` |
| `enabled` | `false` stops calls without deleting |
| `impact`, `verify`, `governed` | Declarations that make the action governed |
| `adapter` | A built-in adapter (`stripe.refund`, `github.file_update`, `resend.email_send`), already governed |

## Placeholders

| Placeholder | Resolves to |
| --- | --- |
| `{{input.field}}` | A validated input value. In the URL it is percent-encoded into its one segment or query value, so it cannot add `/`, `?`, `#` or `..` |
| `{{input.field:path}}` | In the URL path: a value spanning several segments; empty, `.` and `..` segments are refused |
| `{{secret:NAME}}` | A vaulted secret, decrypted at dispatch, redacted from every record |
| `{{connection:<integration>}}` | The connected account's token: the end user's when the call names one, else the workspace's |
| `{{connection.host}}` | URL host for integrations with a per-account host (for example a Zendesk subdomain) |
| `{{response.field}}` | Allowed in `verify.url` and `verify.match`: a field of the write's response |

- A body value that is exactly one `{{input.x}}` keeps the input's type (a number stays a number, a list stays a list) and is left out when the input is absent.
- A query pair whose whole value is one input (`&cursor={{input.cursor}}`) is dropped when that input is absent, null or empty.
- An input value is always data: text that looks like `{{secret:X}}` is sent as text, and a governed action refuses it.
- An action that references a secret has a literal scheme and host. Input may choose the host when the action has no secret, and never for a governed action.

## Input schema subset

`type`, `properties`, `required`, `additionalProperties`, `enum`, `pattern`, `items`, `minLength`, `maxLength`, `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `minItems`, `maxItems`, plus `$schema`, `title`, `description` and `default`. Give every property a `description`: the model reads it. Any other keyword is refused when the action is saved. Input that does not validate is refused before anything is sent. Tight schemas are the cheapest guard against a model inventing arguments.

## Request encoding

JSON by default. Declare `Content-Type: application/x-www-form-urlencoded` in `headers` for form bodies (Stripe's v1 API): nested values use brackets (`metadata[order]=42`), `null` values are left out.

## impact

`{ dimension, unit?, amount, bound }`

- `dimension`: `money`, `resource_mutations`, or a unit you name matching `^[a-z][a-z0-9_]{0,31}$`.
- `unit`: the currency for `money` (`usd`); for anything else the dimension name, and it may be left out.
- `amount`: a **string** expression: an integer literal (`'1'`), `'input.<path>'` or `'count(input.<path>)'`, with one optional `* <integer>`. A number (`amount: 1`) is refused. Must evaluate to a non-negative integer. Money is in minor units (cents).
- `bound`: `exact`, or `upper_bound` when the call changes at most that much.

## verify

`{ url, status, match }`

- `url`: a `GET` on the action's own host. May use `{{input.x}}`, `{{response.x}}`, `{{secret:NAME}}`.
- `status`: the HTTP status the read-back must return.
- `match`: up to 10 `{ path, equals }`. `path` is dotted (`data.status`); `*` matches any list item (`labels.*.name`). `equals` is a literal or exactly one `{{input.x}}` / `{{response.x}}`. `"absent": true` holds when no value there equals it (read back a removal; pair it with a check on the resource id).
- Match grades `confirmed`; a `2xx` with no match grades `acknowledged`.

## Adapter actions

```typescript
await ops.actions.create({
  name: 'refund-order',
  requiresApproval: true,
  adapter: {
    type: 'stripe.refund',
    config: { account: 'acct_…', mode: 'live', currency: 'usd' },
    credentialSecret: 'STRIPE_KEY',
  },
});
```

An adapter builds the request itself and accepts none of `impact`, `verify`, `governed`.

## Changing declarations, versions, deletion

- `ops.actions.declare(name, { impact, verify })` or `ops.actions.update(name, …)`. A field left out is kept; `null` removes it; `governed: false` removes all.
- Declaring on an undeclared action needs `actions:write`. Changing or removing an existing declaration needs `actions:govern`, which no key holds by default.
- Every change to the request, gate or declarations appends an immutable version. The invocation reports `actionVersionId`.
- `ops.actions.delete(name)` archives the action. Its name stays taken in the environment. Deleting a gated action needs `actions:govern`.

## Scopes

| Operation | Scope |
| --- | --- |
| Store a secret | `secrets:write` |
| Define an action that references a secret | `actions:write` + `secrets:read` |
| Create or change a policy | `policies:write` |
| Create or change a limit | `impact-limits:write` |
| Ungate, or lower a declaration | `actions:govern` |
| Invoke | `actions:invoke` (the agent's key) |
