---
title: "TypeScript SDK"
description: "Call Anlyon from TypeScript: act for each of your users, invoke governed actions, wait for approvals, read receipts and verify webhooks from one typed client."
---

`@anlyonhq/sdk` is the typed client for the Anlyon API. Your server uses it to let an agent act in your users' own accounts (Gmail, GitHub, HubSpot, Slack...) without your code ever holding their tokens.

Without it you would build per-user OAuth and token storage, a per-user limit counter, an approval backend and a record of what ran, for every integration. With it, your server names the user once (`anlyon.forUser(id)`), the agent calls an action by name, and Anlyon adds the user's token at dispatch, checks the user's limit, waits for the user's approval on risky steps and returns a receipt with a grade.

The same calls work with your company's own accounts: leave out `forUser()` and the call uses the workspace's connections and your team's approvals.

This page documents `@anlyonhq/sdk` 2.3. It needs Node.js 18 or later, or another runtime with a global `fetch`, such as Cloudflare Workers. It ships ESM and CommonJS builds with full types.

## Install the SDK

```bash npm
npm install @anlyonhq/sdk
```

```bash pnpm
pnpm add @anlyonhq/sdk
```

```bash yarn
yarn add @anlyonhq/sdk
```

## Make your first call for a user

Two keys, two jobs. An **operator key** declares the action and the rules around it, once, from your deploy script. An **app key** runs in your server, names the user on every call, and can change none of the rules. See [Authentication](/authentication) for creating keys.

```typescript
import { Client, EndUserNotConnectedError } from '@anlyonhq/sdk';

// Your server's key. It never reaches the browser or the model.
const anlyon = new Client({ apiKey: process.env.ANLYON_API_KEY! });

// Your server names the user from its own session. The model never does.
const user = anlyon.forUser(session.user.id);

try {
  const { data: call } = await user.actions.invoke('open-issue', {
    title: 'Checkout fails on Safari',
    body: 'Steps to reproduce: ...',
  });

  if (call!.pendingApproval) {
    // Parked until this user approves it in your app.
    console.log('Waiting for the user:', call!.approvalId);
  } else {
    console.log(call!.status, call!.grade); // e.g. 'succeeded' 'confirmed'
  }
} catch (error) {
  // The user has not connected GitHub yet. Nothing was sent.
  if (error instanceof EndUserNotConnectedError) return { connectUrl: error.connectUrl };
  throw error;
}
```

The action `open-issue` is the one the [quickstart](/quickstart) declares. Every section below takes one part of this loop further.

## Configure the client

```typescript
import { Client } from '@anlyonhq/sdk';

const anlyon = new Client({
  apiKey: process.env.ANLYON_API_KEY!,      // required: anlyon_live_...
  baseUrl: 'https://api.anlyon.com',        // the default
  timeout: 30_000,                          // per request, in milliseconds
  retry: {
    maxAttempts: 3,
    initialDelay: 100,
    maxDelay: 10_000,
    backoffMultiplier: 2,
    jitter: true,
    retryableStatusCodes: [429, 500, 502, 503, 504],
  },
  logging: { enabled: false, level: 'info', redactSensitive: true },
});
```

| Option | Default | What it does |
| --- | --- | --- |
| `apiKey` | none, required | An `anlyon_live_...` key. A key belongs to one environment. The constructor throws without one. |
| `baseUrl` | `https://api.anlyon.com` | The API origin. Paths are under `/api/v2`. |
| `timeout` | `30000` ms | Per request. A request that runs out throws `TimeoutError`. |
| `retry.maxAttempts` | `3` | Attempts in total, the first one included. |
| `retry.initialDelay` | `100` ms | First wait, multiplied by `backoffMultiplier` (default `2`) each time, up to `maxDelay` (default `10000` ms). With `jitter` (default `true`) each wait is a random time up to that delay. |
| `retry.retryableStatusCodes` | `429, 500, 502, 503, 504` | Statuses that are sent again. Network errors and timeouts are always sent again. A `429` with `Retry-After` waits that long. |
| `logging` | off | `level` is `debug`, `info`, `warn` or `error`. `redactSensitive` defaults to `true`. Pass `logger` to use your own. |
| `defaultHeaders` | none | Headers added to every request. `Anlyon-End-User` is refused here: use `forUser()`. |
| `fetch` | `globalThis.fetch` | A custom `fetch`, for tests or unusual runtimes. |

The SDK retries every request on those conditions, writes included. That is safe because each write that accepts an `Idempotency-Key` carries one, and the SDK reuses it on its own retries, so a resend is answered from the first attempt's record. See [Make a call safe to repeat](#make-a-call-safe-to-repeat).

## Check the key at startup

A key with a missing scope fails at the first call that needs it, often in production. A key with too much (for example `approvals:decide`, which would let your app approve its own requests) fails silently. Check both when your server starts:

```typescript
import { Client, ScopeCheckError } from '@anlyonhq/sdk';

const anlyon = new Client({ apiKey: process.env.ANLYON_API_KEY! });

try {
  await anlyon.auth.requireScopes(
    ['actions:read', 'actions:invoke', 'end-users:write', 'end-users:approvals'],
    { forbidden: ['approvals:decide', 'policies:write', 'impact-limits:write', 'secrets:write'] },
  );
} catch (error) {
  if (error instanceof ScopeCheckError) {
    console.error('Wrong key. Missing:', error.missing, 'Must not hold:', error.forbidden);
    process.exit(1);
  }
  throw error;
}

// Or read the identity yourself.
const { data: identity } = await anlyon.auth.identity();
console.log(identity!.credentialType, identity!.environmentId, identity!.scopes, identity!.expiresAt);
```

`auth.identity()` calls `GET /api/v2/auth/identity` and needs no scope. It never returns a secret. Holding a scope is not proof that a call will run: policies, limits, a halted environment and quotas still apply at the call.

## Act for one of your users

`anlyon.forUser(endUserId)` returns a new client that sends `Anlyon-End-User: <endUserId>` on every request. Everything that client does happens for that user:

- An action that uses `{{connection:<integration>}}` gets **that user's** connected account, never the workspace's.
- Each write counts against the user's own [impact limit](/execution/impact-limits) when the limit's scope is `end_user`.
- An approval policy with `decidedBy: "end_user"` sends the approval to **that user**, in your app, not to your team.
- Invocations, approvals and receipts carry the `endUserId`.

```typescript
const anlyon = new Client({ apiKey: process.env.ANLYON_API_KEY! });

const user = anlyon.forUser(session.user.id); // from your own session
console.log(user.endUserId);   // the id
console.log(anlyon.endUserId); // undefined: the parent stays a workspace client
```

The rules that keep the user out of the model's hands:

- The id is your own id for the user: 1 to 128 characters from `A-Z a-z 0-9 . _ : @ -`. `forUser()` checks it before anything is sent and throws an `AnlyonError` with code `VALIDATION_ERROR` otherwise. Use `isValidEndUserId(value)` to check one yourself.
- The binding is fixed. Calling `forUser()` with another id on a bound client throws. Build each user's client from the workspace client.
- Only `forUser()` can set the header. A `defaultHeaders` entry for it is refused.
- Tool schemas never carry a user field. Hand the bound client to your tool adapter, and the model can only choose the action and its input.

Create the app key with **Require an end user** (`endUserRequired: true`) when it should never touch the workspace's own accounts. Every action call it makes without a user is refused with `400 END_USER_REQUIRED` (`EndUserRequiredError`).

Over HTTP, the header is all there is to it:

```bash
curl -X POST https://api.anlyon.com/api/v2/actions/open-issue/invoke \
  -H "Authorization: Bearer $ANLYON_API_KEY" \
  -H "Anlyon-End-User: alice" \
  -H "Content-Type: application/json" \
  -d '{ "input": { "title": "Checkout fails on Safari", "body": "Steps to reproduce: ..." } }'
```

### Connect a user's account

Each user connects their own account once. For an OAuth integration your server makes a connect link, the user signs in at the provider, and your callback confirms the connection for the user in **your** session. Needs `end-users:write`.

```typescript
import { Client, parseConnectRedirect } from '@anlyonhq/sdk';

const anlyon = new Client({ apiKey: process.env.ANLYON_API_KEY! });

// 1. The Connect button. The link works once, for 10 minutes.
const { data: link } = await anlyon.endUsers.createConnectLink({
  endUserId: session.user.id,
  integration: 'github',
  redirectUrl: 'https://app.example.com/connections/callback',
});
// Redirect the browser to link!.url

// 2. The callback route. Use the user from your session, never one read from the URL.
export async function GET(request: Request) {
  const session = await requireSession();
  const result = parseConnectRedirect(request.url);
  if (result?.status === 'pending_confirmation' && result.flowId) {
    await anlyon.endUsers.confirmConnection(session.user.id, result.flowId);
    // Same thing on a bound client: anlyon.forUser(session.user.id).connections.confirm(result.flowId)
  }
  return Response.redirect(new URL('/settings/connections', request.url));
}
```

- `redirectUrl` must fall under one of the environment's allowed redirect URLs. Leave it out to use the environment's default redirect URL. With neither, the call throws `RedirectUrlRequiredError`. Set both in the console under **Integrations → Connect settings**.
- The redirect carries `anlyon_status` (`pending_confirmation`, `connected`, `declined` or `error`), `integration` and, while the grant waits, `anlyon_flow`. `parseConnectRedirect()` reads them. It never returns a user id: the URL is not a source of truth.
- Until your server confirms, the connection does not exist. A flow started for another user throws `EndUserMismatchError` (`403 END_USER_MISMATCH`), and nothing is connected. That is what stops a forwarded link from attaching someone else's account to your user.
- At most 10 links per minute per user, and 300 per environment.

For an API-key integration (GitHub, Notion, Jira...) your server can store the user's own key directly. Anlyon checks it with the provider when the catalogue can, encrypts it and never returns it:

```typescript
await anlyon.endUsers.setConnection(session.user.id, 'github', { token: keyFromYourForm });
await anlyon.endUsers.setConnection(session.user.id, 'jira', { fields: { site: 'acme', email, apiToken } });

const { data: connections } = await anlyon.endUsers.listConnections(session.user.id); // end-users:read
await anlyon.endUsers.deleteConnection(session.user.id, 'github');                    // revokes, then deletes
```

A key the provider refuses is not stored (`422 CONNECTION_KEY_REJECTED`). A connection's `status` is `active`, `needs_reconnect` or `revoked`. See [Workspace connections and end-user connections](/execution/end-user-connections).

### Handle a user who has not connected

An action call for a user with no working connection throws before anything is sent:

```typescript
import { EndUserConnectionRequiredError, EndUserReconnectRequiredError } from '@anlyonhq/sdk';

try {
  await anlyon.forUser(userId).actions.invoke('open-issue', input);
} catch (error) {
  // Catches both "never connected" and "connect again".
  if (error instanceof EndUserConnectionRequiredError) {
    const reconnect = error instanceof EndUserReconnectRequiredError;
    const url = error.connectUrl
      ?? (await anlyon.endUsers.createConnectLink({ endUserId: userId, integration: error.integration })).data!.url;
    return { action: reconnect ? 'reconnect' : 'connect', integration: error.integration, url };
  }
  throw error;
}
```

| Error | Code | Meaning |
| --- | --- | --- |
| `EndUserNotConnectedError` | `409 END_USER_NOT_CONNECTED` | The user never connected this integration. |
| `EndUserReconnectRequiredError` | `409 END_USER_RECONNECT_REQUIRED` | The provider revoked the grant, or a refresh failed. |

Both carry `integration`, `endUserId`, `connectUrl` and `expiresAt`. `connectUrl` is a fresh link that works once, for 10 minutes. It is `null` when the environment has no default redirect URL, or when the integration connects with a key: make a link with `createConnectLink()` then.

The [AI SDK tools](/guides/act-for-your-users) and the MCP server return `{ status: "connect_required", integration, connectUrl, message }` as a tool result instead of throwing, so the model can tell the user.

### Let the user decide their approvals

An approval policy with `decidedBy: "end_user"` sends each approval to the user the call was made for. It never appears in your team's inbox. Your app lists and decides it. Needs `end-users:approvals`.

```typescript
// What this user sees in your app: their own approvals, nobody else's.
const { data: pending } = await anlyon.endUsers.approvals.list(session.user.id, { status: 'pending' });

for (const approval of pending ?? []) {
  console.log(approval.id, approval.title, approval.payload); // the exact request
}

// The user clicked Approve. Approving runs the parked call.
const { data: decision } = await anlyon.endUsers.approvals.decide(session.user.id, approvalId, {
  decision: 'approve', // or 'deny'
  reason: 'Looks right',
});
console.log(decision!.approval.status, decision!.invocation?.status, decision!.invocation?.grade);
```

An approval that belongs to another user, or one the team decides, answers `404`. Take the user id from your session, never from the request.

### Give the browser a short-lived session

The prebuilt React components in `@anlyonhq/react` run in the browser and must never see your API key. Mint a session token for the signed-in user instead:

```typescript
// app/api/anlyon/session/route.ts
const session = await requireSession();
const { data } = await anlyon.endUsers.createSession(session.user.id, { ttlSeconds: 900 });
return Response.json(data, { headers: { 'Cache-Control': 'no-store' } });
```

The token (`eus_...`) lasts 15 minutes by default, 60 to 3600 seconds on request, and never past the key's own expiry. It can list, connect and disconnect that user's accounts and, when the key holds `end-users:approvals`, list and decide their approvals. Nothing else: no action call, no secret, no other user, and it cannot confirm a connection. Needs `end-users:write`, and an API key. See [Components](/sdks/react).

### List and erase users

```typescript
// Users with connections or activity in this environment. Needs end-users:read.
let cursor: string | undefined;
do {
  const page = await anlyon.endUsers.list({ cursor, limit: 50 });
  for (const u of page.data) console.log(u.endUserId, u.connections, u.invocations, u.pendingApprovals);
  cursor = page.pagination.nextCursor ?? undefined;
} while (cursor);

// A user deletes their account. Needs end-users:write.
const { data: erased } = await anlyon.endUsers.erase('alice');
console.log(erased!.connectionsDeleted, erased!.approvalsCancelled, erased!.countersCleared, erased!.erasedAs);
```

Erasing revokes the user's connections at the provider where it can and deletes them, clears their limit counters and cancels their pending approvals. Invocations, receipts, approvals and audit records are kept with the id replaced by a one-way hash, `erasedAs`. A client from `forUser()` cannot list other users.

## Define an action

An action is one production call, defined once by an operator key: the URL, the headers, the input schema, and where the credential goes. The agent sends only the input. Needs `actions:write`.

```typescript
const ops = new Client({ apiKey: process.env.ANLYON_OPERATOR_KEY! });

await ops.actions.create({
  name: 'open-issue',
  description: 'Open one issue in the support repository.',
  method: 'POST',
  urlTemplate: 'https://api.github.com/repos/acme/support/issues',
  headers: {
    // The connected account of the user the call is made for.
    Authorization: 'Bearer {{connection:github}}',
    Accept: 'application/vnd.github+json',
    'User-Agent': 'acme-agent',
    'Content-Type': 'application/json',
  },
  bodyTemplate: { title: '{{input.title}}', body: '{{input.body}}' },
  inputSchema: {
    type: 'object',
    required: ['title', 'body'],
    additionalProperties: false,
    properties: {
      title: { type: 'string', minLength: 1, maxLength: 256 },
      body: { type: 'string', minLength: 1, maxLength: 20000 },
    },
  },
  // Counted before dispatch, so a limit can cap it.
  impact: { dimension: 'issues', amount: '1', bound: 'exact' },
  // Read back after the write. A match grades the receipt `confirmed`.
  verify: {
    url: 'https://api.github.com/repos/acme/support/issues/{{response.number}}',
    status: 200,
    match: [{ path: 'title', equals: '{{input.title}}' }],
  },
});
```

- Templates take `{{input.<field>}}`, `{{secret:NAME}}` for a vaulted secret and `{{connection:<integration>}}` for a connected account. `{{connection:github}}` is the user's own account on a `forUser()` call, and the workspace's account otherwise.
- An action with `impact`, `verify` or `governed: true` is **governed**: it needs a fixed `https` host, its impact is reserved against limits before dispatch, and every call returns a receipt grade.
- `requiresApproval: true` parks every call for a decision. An [approval policy](#route-approvals-with-policies) can do the same with conditions and send it to the user.
- An adapter action (`adapter: { type: 'stripe.refund', ... }`) builds the request itself. See [Governed effects](/execution/governed-effects).
- `actions.importFromOpenApi(spec, { auth, dryRun: true })` turns a whole OpenAPI 3.x spec into actions. Read `skipped` for what it left out. See [Any HTTP API](/guides/any-http-api).

**Change what an action declares**

```typescript
await ops.actions.declare('open-issue', { verify: null });     // remove the read-back
await ops.actions.declare('open-issue', { governed: false });  // remove every declaration
await ops.actions.update('open-issue', { description: 'Open one support issue.' });
```

`declare()` sends only `impact`, `verify` and `governed`. A field you leave out is unchanged, and `null` removes it. Changing what a governed action already declares needs `actions:govern`. Every edit appends an immutable version: read them with `ops.actions.versions('open-issue')`.

**List and read actions**

```typescript
const { data: actions } = await anlyon.actions.list();          // actions:read
const { data: action } = await anlyon.actions.get('open-issue'); // by name or act_ id
await ops.actions.delete('open-issue');                         // archives it; the name stays taken
```

## Invoke an action

```typescript
const user = anlyon.forUser('alice');

const { data: call } = await user.actions.invoke(
  'open-issue',
  { title: 'Checkout fails on Safari', body: 'Steps to reproduce: ...' },
  { operationKey: `ticket:${ticket.id}` }, // your business identity for this write
);
```

Needs `actions:invoke`. The response is the invocation:

```json
{
  "id": "inv_01JABCDEF",
  "actionName": "open-issue",
  "status": "pending_approval",
  "pendingApproval": true,
  "approvalId": "apr_01JABCDEF",
  "responseStatus": null,
  "body": null,
  "effectId": "eff_01JABCDEF",
  "grade": "pending",
  "endUserId": "alice"
}
```

| `status` | What it means | What to do |
| --- | --- | --- |
| `pending_approval` | Parked for a decision. Nothing was sent. | Wait, or come back later by `id` or `approvalId`. Do not invoke again. |
| `running` | Sent, not finished. | Read the same invocation later. |
| `succeeded` | The destination answered 2xx. `responseStatus` and `body` (a raw string) hold its reply. | Report it, with `grade`. |
| `failed` | Refused, or rejected by the destination. `error` says why. | Read `error`. A retry is a new call with `retryOf`. |
| `unknown` | The request may have reached the destination and the outcome cannot be confirmed. | Never retry. Reconcile first. See [When the outcome is unknown](/execution/when-the-outcome-is-unknown). |
| `denied` / `expired` | The approval was denied, or nobody decided in time. | Stop. Do not resubmit to get around the decision. |

Other invoke options: `runId` and `parentSpanId` attach the call to a [run](#trace-a-run), `previewId` binds it to a reviewed [preview](/execution/previews), `dependsOn` names effects that must already hold an outcome, `compensates` marks a compensation, and `retryOf` names the failed invocation this one deliberately retries (with a new `idempotencyKey`).

### Read the receipt

A governed action answers with a `grade` on the invoke response and on single invocation reads. It is absent for an action that is not governed.

| Grade | Meaning |
| --- | --- |
| `confirmed` | A read-back matched the request. |
| `acknowledged` | The provider accepted the request. Nothing read it back. |
| `unknown` | No usable response. Anlyon does not send it again. It stays unknown until a read-back or an operator settles it. |
| `failed` | The provider rejected the request, or it never left Anlyon. |
| `refused` | Anlyon refused it at dispatch. |
| `denied` | The approval was denied. |
| `pending` | No receipt yet. |

```typescript
// One invocation, later: from a job, a webhook handler or the next turn. actions:read
const { data: invocation } = await user.actions.invocation('inv_01JABCDEF');
console.log(invocation!.status, invocation!.grade, invocation!.decision?.explanation);

// The receipt itself, with its evidence.
const { data: effect } = await user.actions.effect(invocation!.effectId!);
console.log(effect!.grade, effect!.outcome, effect!.verification);

// Lists, filtered. Paginated with limit and offset.
await anlyon.actions.invocations({ action: 'open-issue', status: 'unknown', endUserId: 'alice' });
await anlyon.actions.effects({ grade: 'acknowledged', actingFor: 'end_users' });
```

`actingFor: 'workspace'` lists only calls made with the workspace's own accounts, `'end_users'` only calls made for a user. `effects()` also filters by `action`, `outcome`, `stage` and `unresolved`. See [Receipts and grades](/execution/outcomes).

**Preview a governed call before it runs**

```typescript
const { data: preview } = await user.actions.preview('open-issue', { title, body }, { ttlSeconds: 3600 });
console.log(preview!.request, preview!.impact, preview!.expiresAt);

await user.actions.invoke('open-issue', preview!.request, { previewId: preview!.id, operationKey: 'ticket:42' });
```

A preview reads the provider and writes nothing (`actions:invoke`). Its TTL is 60 to 604800 seconds, default 3600. `refreshPreview(id)` makes a new one; an approval of the old one never carries over. See [Previews](/execution/previews).

**Settle an unknown outcome (operator key)**

```typescript
// Template action: record what the destination confirms. Sends nothing.
await ops.actions.resolveInvocation('inv_...', {
  outcome: 'succeeded',
  evidence: 'Issue #812 visible in GitHub at 14:02 UTC.',
  externalReference: '812',
});

// Governed effect: ask the provider read-only, then record a reasoned resolution.
await ops.actions.reconcileEffect('eff_...');                         // actions:invoke
await ops.actions.resolveEffect('eff_...', { outcome: 'failed', reason: 'No issue with that title exists.' });
```

Resolving needs `actions:resolve`, and the key that made the call can never resolve it, so an agent cannot mark its own uncertain call failed and retry it.

## Make a call safe to repeat

Networks drop replies. Without protection, the retry that follows opens a second issue or sends a second refund.

- **Transport retries are covered for you.** The SDK sends an `Idempotency-Key` on every invoke (from 2.2.0), generates one when you pass none, and reuses it on its own retries. A resend is answered from the recorded invocation and is never executed again.
- **Your own retries need your own key.** A generated key covers one call to `invoke()`. When your code can call `invoke()` twice for one business operation (a queue redelivery, a user double-click, a graph that resumes), pass a key derived from that operation.

```typescript
await user.actions.invoke('open-issue', input, {
  idempotencyKey: `ticket:${ticket.id}:open-issue`, // replays the recorded result for 24 hours and beyond
  operationKey: `ticket:${ticket.id}`,              // governed actions: same key + same request = the same effect
});
```

| Situation | Answer |
| --- | --- |
| Same `idempotencyKey`, same body | The recorded result. Never executed, admitted or charged again. |
| Same key, different body or a different key holder | `409 idempotency_key_reuse` |
| Same key while the first request is still running | `409 idempotency_request_in_progress`. The error names the key: call again with it to get the original's result. |
| Same `operationKey`, different request (governed action) | `409`. One business operation maps to one effect. |

An `unknown` outcome is never sent again by Anlyon, by any key. See [Idempotency and retries](/execution/idempotency).

## Store secrets

For a credential that is not a connected account (a Stripe key, a webhook token), use the vault. Values go in and never come back out: there is no method that reads a value. Needs `secrets:write`.

```typescript
await ops.secrets.put('STRIPE_KEY', {
  value: process.env.STRIPE_KEY!,
  allowedHosts: ['api.stripe.com'],  // never sent anywhere else
  allowedPlacements: ['header'],     // 'url', 'header' or 'body'
});

const { data: secrets } = await ops.secrets.list(); // names and metadata only (secrets:read)
await ops.secrets.delete('OLD_TOKEN');
```

An action references it as `{{secret:STRIPE_KEY}}`. Names match `^[A-Z0-9_]+$`. Rotating a secret without `allowedHosts` keeps its host list, so a rotation never widens where a credential may go. See [Secrets](/execution/secrets).

## Wait for an approval

When a call parks, you can wait for the decision or come back later. Both read the same approval.

```typescript
const { data: call } = await user.actions.invoke('open-issue', input, { operationKey: `ticket:${ticket.id}` });

if (call!.pendingApproval) {
  // Long-polls in 55-second hops. Default timeout 5 minutes.
  const decided = await user.approvals.waitForDecision(call!.approvalId!, { timeoutMs: 10 * 60_000 });

  if (decided.status === 'pending') {
    // Not an error: store call.id and read it later.
  } else {
    const { data: done } = await user.actions.invocation(call!.id);
    console.log(decided.status, done!.status, done!.grade);
  }
}
```

A timeout returns the approval still `pending`: always branch on `status` (`pending`, `approved`, `denied` or `expired`). Reading an approval needs `approvals:read`. An approval expires after 24 hours by default; see [When approval expires or changes](/execution/when-approval-expires-or-changes).

**Ask a person directly**

```typescript
const { data: approval } = await anlyon.approvals.request({
  title: 'Send the contract to Acme?',
  description: 'Draft is ready. Sending is irreversible.',
  payload: { draftId: 'doc_9' },
  expiresInSeconds: 3600, // default: the workspace's window, 24 hours unless changed
});
const decided = await anlyon.approvals.waitForDecision(approval!.id);
```

Needs `approvals:write`.

**Decide as your team (operator key)**

```typescript
const operator = new Client({ apiKey: process.env.ANLYON_OPERATOR_KEY! });

const inbox = await operator.approvals.list({ status: 'pending', decider: 'team' });

// Approving an action's approval also runs the parked call.
const { data } = await operator.approvals.approve('apr_...', { note: 'Checked the order.' });
console.log(data!.invocation?.status, data!.selfApproved);

await operator.approvals.deny('apr_...', { note: 'Wrong customer.' });
```

Deciding needs `approvals:decide`, and a key can never decide an approval it requested. Approvals with `decider: 'end_user'` show in the list read-only: the user decides them through `endUsers.approvals`.

**Gate your own function (fallback)**

```typescript
const refund = anlyon.approvals.gate(
  { title: 'Refund a customer?', timeoutMs: 10 * 60_000 },
  async (orderId: string, amount: number) => stripe.refunds.create({ /* ... */ }),
);

await refund('ord_42', 129.99); // runs only after an approver says yes
```

The wrapper requests an approval, waits, and then runs your function. Denied or expired throws `ApprovalDeniedError`; a wait that runs out throws `ApprovalPendingError` and your function does not run. Your process still holds the credential, so this gives you the decision and the record, not credential isolation. See [Local approval gates](/guides/approval-gates).

## Route approvals with policies

A policy decides, before dispatch, whether a call needs approval, is approved or is denied, and who decides it. Policies are set by an operator key with `policies:write`: the app key that a policy governs cannot change it.

```typescript
const ops = new Client({ apiKey: process.env.ANLYON_OPERATOR_KEY! });

// Every open-issue call waits for the user it acts for.
await ops.approvalPolicies.create({
  name: 'The user approves each issue',
  matchKind: 'action',
  matchActionName: 'open-issue',
  effect: 'require_approval',
  decidedBy: 'end_user',
});

// Large refunds need two of your team.
await ops.approvalPolicies.create({
  name: 'Refunds over $500',
  matchKind: 'action',
  matchActionName: 'refund-order',
  condition: 'input.amount >= 50000',
  effect: 'require_approval',
  requiredApprovals: 2,
});

// What would decide this call? Read-only. policies:read
const { data: evaluation } = await ops.approvalPolicies.evaluate({ kind: 'action', actionName: 'refund-order', payload: { amount: 60000 } });
console.log(evaluation!.decision.effect, evaluation!.decision.explanation);
```

- `effect` is `require_approval`, `auto_approve` or `auto_deny`. `matchKind` is `action`, `custom` or `any`; nothing is inferred from the name.
- `decidedBy` is `team` (the default: the console, Slack or a key with `approvals:decide`) or `end_user`. An `end_user` policy takes one decision, whatever `requiredApprovals` says, and a call with no user is always decided by the team.
- `condition` is a [CEL](https://cel.dev) expression over the input. A condition that errors at run time requires approval.
- `list`, `get`, `update`, `enable`, `disable`, `delete` and `versions` manage policies. `planFile`, `testFile` and `applyFile` keep them in a YAML file in your repository. See [Policy as code](/trust-control/policy-as-code) and [Approvals](/trust-control/approvals).

## Cap what an agent can do

An impact limit caps how much the governed actions in an environment may change: money, resource mutations, or a unit an action declares (`issues`, `emails`). Every applicable limit is reserved immediately before dispatch, so an approval granted earlier cannot push past it. Changing limits needs `impact-limits:write`, which no key gets by default.

```typescript
// Each user may open 5 issues a day. One user cannot use up another's.
await ops.impactLimits.create({
  name: 'Issues per user per day',
  dimension: 'issues',
  period: 'day',
  amount: 5,
  actionName: 'open-issue',
  scope: 'end_user',
});

// One shared cap on refunds, in cents.
await ops.impactLimits.create({ name: 'Daily refunds', dimension: 'money', currency: 'usd', period: 'day', amount: 50_000 });

const { data: limits } = await anlyon.impactLimits.list(); // impact-limits:read
for (const l of limits ?? []) console.log(l.name, l.scope.kind, l.consumed, l.reserved, l.available);
```

`period` is `day`, `month` or `total`. `scope` is `environment` (the default: every call draws from one amount) or `end_user` (each user has the whole amount; a call with no user counts on the shared amount). Scope is fixed once created. `update()` renames or changes the amount, and `archive()` stops applying it to new calls. A call over the limit is refused before it is sent and graded `refused`. See [When the limit is hit](/execution/when-the-limit-is-hit).

## Trace a run

A run is the trace an agent's work is attributed to. Pass its id when invoking, and the invocation, the approval wait and the decision join the same timeline.

```typescript
const { data: run } = await anlyon.runs.start({ name: 'support-triage', agentId: 'agt_123' });

await user.actions.invoke('open-issue', input, { runId: run!.id });

await anlyon.runs.end(run!.id, { status: 'succeeded', output: { opened: 1 } });

const { data: trace } = await anlyon.runs.get(run!.id);        // the run with its span tree
const { data: otlp } = await anlyon.runs.exportTrace(run!.id); // redacted OpenTelemetry Protocol JSON
```

<a id="functions"></a>

## Run your own code as a hosted function

A hosted function is your own JavaScript or Python, run with no network and no secrets of its own. It changes things only by calling the actions its version allows. A plan run records its writes without sending them, one approval covers that exact list, and an apply run sends it.

```typescript
await ops.functions.create({ name: 'close-stale-issues' });                         // functions:write
await ops.functions.publish('close-stale-issues', { language: 'javascript', source });

const user = anlyon.forUser('alice');
const input = { olderThanDays: 30 };

// functions:run and actions:invoke
const { data: plan } = await user.functions.run('close-stale-issues', { input });

if (plan!.status === 'awaiting_approval') {
  // Once plan.plan.approval is approved (by the team, or by the user when every write's policy says end_user):
  const { data: applied } = await user.functions.run('close-stale-issues', { input, mode: 'apply', planRunId: plan!.id });
  const { data: run } = await user.functions.runs.get(applied!.id);
  console.log(run!.status, run!.receipt);
}
```

A run ends `planned`, `awaiting_approval`, `succeeded`, `failed`, `budget_exceeded` or `diverged` (an apply write that differs from the plan, stopped before it is sent). An apply answers `409` with `PLAN_NOT_APPROVED`, `PLAN_ALREADY_APPLIED` or `PLAN_INPUT_MISMATCH`, and `503 FUNCTION_RUNNER_UNAVAILABLE` when the runner cannot be reached. See [Run your own code](/guides/run-your-own-code) and [Hosted functions](/execution/hosted-functions).

## Verify webhooks

Subscription deliveries that Anlyon signs carry an `Anlyon-Signature` header. Verify it before you trust the body: anyone can POST to your endpoint. A notification webhook is signed differently, with `X-Anlyon-Signature: sha256=<hex>` when a webhook secret is set, and the `Receiver` does not verify that format. See [Notifications](/account/notifications).

```typescript
import { Receiver } from '@anlyonhq/sdk';

const receiver = new Receiver({
  signingSecret: process.env.ANLYON_SIGNING_SECRET!,
  clockTolerance: 300, // seconds, the default
});

export async function POST(req: Request) {
  const body = await req.text(); // the raw body, before any JSON parsing
  if (!receiver.verify({ signature: req.headers.get('anlyon-signature'), body })) {
    return new Response('Unauthorized', { status: 401 });
  }
  await handleEvent(JSON.parse(body));
  return new Response('ok');
}
```

`receiver.verifyOrThrow({ signature, body })` throws `SignatureVerificationError` instead. A signature older than `clockTolerance` fails, which stops a captured delivery from being replayed.

The Next.js helpers verify before your handler runs. Both read `ANLYON_SIGNING_SECRET` by default:

```typescript
// app/api/anlyon/route.ts
import { serve } from '@anlyonhq/sdk/nextjs';

export const { POST } = serve(
  async (request) => {
    const event = await request.json();
    await handleEvent(event);
    return { success: true };
  },
  {
    clockTolerance: 600, // default 300
    onError: (error) => Response.json({ error: error.message }, { status: 400 }),
  },
);
```

`verifySignature(handler)` from `@anlyonhq/sdk/nextjs` wraps a handler that returns its own `Response`. Helpers for Hono, Express, Fastify, SvelteKit, h3 and Cloudflare Workers ship in the same package. See [Webhooks](/guides/webhooks) for event types and payloads.

## Handle errors

Every error the SDK throws is an `AnlyonError` with `code`, `status`, `message`, `details`, `requestId` and `retryAfter`. Quote `requestId` to support. The SDK has already retried what is retryable by the time you see an error.

```typescript
import {
  AnlyonError,
  EndUserConnectionRequiredError,
  EndUserRequiredError,
  QuotaExceededError,
  RateLimitError,
  ValidationError,
} from '@anlyonhq/sdk';

try {
  await anlyon.forUser(userId).actions.invoke('open-issue', input);
} catch (error) {
  if (error instanceof EndUserConnectionRequiredError) return showConnectButton(error.integration, error.connectUrl);
  if (error instanceof EndUserRequiredError) throw new Error('Build the client with forUser() on the server.');
  if (error instanceof ValidationError) return badInput(error.details);
  if (error instanceof RateLimitError) return retryLater(error.retryAfter);
  if (error instanceof QuotaExceededError) return tellTheUser('Monthly allowance used up.');
  if (error instanceof AnlyonError && error.status === 401) throw new Error('Bad or revoked API key.');
  if (error instanceof AnlyonError && error.status === 404) return notFound();
  throw error;
}
```

| Error | When | What to do |
| --- | --- | --- |
| `ValidationError` | `400 VALIDATION_ERROR` from the API: the input does not match the schema, or a field is malformed. | Fix the request. `details` names the fields. |
| `AnlyonError`, code `VALIDATION_ERROR`, no `status` | Checks the SDK makes before sending anything: a malformed end-user id, `forUser()` on an already bound client, a `ttlSeconds` out of range, `apply` without `planRunId`. | Fix the call. Nothing was sent. |
| `EndUserRequiredError` | `400 END_USER_REQUIRED`: the key requires an end user and the call named none. | Call through `anlyon.forUser(id)`. |
| `RedirectUrlRequiredError` | `400 REDIRECT_URL_REQUIRED` from `createConnectLink()`. | Pass an allowed `redirectUrl`, or set a default redirect URL. |
| `AnlyonError`, `status` `401` | The key is wrong, expired or revoked (`AUTHENTICATION_ERROR`). | Check `ANLYON_API_KEY` and the key's environment. Branch on `status`, not on `AuthenticationError`. |
| `AnlyonError`, code `PAYMENT_REQUIRED` | `402`: the workspace has no plan. Nothing was recorded. | Start a plan. See [Billing](/account/billing). |
| `FeatureNotAvailableError` | `402 FEATURE_NOT_AVAILABLE`: the plan does not include the feature. | See [anlyon.com/pricing](https://anlyon.com/pricing). |
| `AnlyonError`, code `AUTHORIZATION_ERROR` | `403`: the key lacks the scope. The message names it. | Create a key with that scope. Check at startup with `auth.requireScopes()`. |
| `EndUserMismatchError` | `403 END_USER_MISMATCH` from `confirmConnection()`: the flow was started for another user. Nothing was connected. | Confirm only with the user from your session. |
| `NotFoundError`, or `AnlyonError` with `status` `404` | The action, invocation, approval or user does not exist in this environment, or belongs to another user. Specific codes such as `ACTION_NOT_FOUND` arrive as `AnlyonError`. | Check the name and the key's environment. Branch on `status`. |
| `EndUserNotConnectedError` | `409 END_USER_NOT_CONNECTED`. Nothing was sent. | Send the user to `connectUrl`, then call again. |
| `EndUserReconnectRequiredError` | `409 END_USER_RECONNECT_REQUIRED`. Nothing was sent. | Send the user to `connectUrl` to connect again. |
| `AnlyonError`, code `ENVIRONMENT_HALTED` | `409`: an operator halted the environment. Nothing leaves it. | Stop and alert. Resuming is an operator act. |
| `AnlyonError`, code `idempotency_key_reuse` or `idempotency_request_in_progress` | `409`. See [Make a call safe to repeat](#make-a-call-safe-to-repeat). | Reuse the key with the same body, or wait and call again with it. |
| `RateLimitError` | `429 RATE_LIMIT_EXCEEDED`, after the SDK's own retries. | Wait `retryAfter` seconds. |
| `QuotaExceededError` | `QUOTA_EXCEEDED`: past the plan's monthly action allowance. | Wait for the reset or change plan. |
| `AnlyonError`, code `BUDGET_EXCEEDED` | `429`: the key's own spend cap is used up. | Raise the budget with an operator key, or wait for the month. |
| `NetworkError`, `TimeoutError` | No response, after the SDK's retries. No `status`. | For an invoke, call again with the **same** `idempotencyKey`, never a new one. |
| `ScopeCheckError` | From `auth.requireScopes()`. `missing` and `forbidden` list the scopes. | Use a key with exactly the scopes your app needs. |
| `ApprovalDeniedError`, `ApprovalPendingError` | From `approvals.gate()` only. `approval` holds the decided or still-pending approval. | Do not run the step. A pending approval can still be decided. |

A gated action never throws for a pending decision: it returns `pendingApproval: true`. A refused call (a limit, a policy) is not an exception either: it returns `status: 'failed'` or `'denied'` with the reason in `error` or `decision`, and a governed receipt graded `refused` or `denied`.

## Use the types

The SDK exports its request and response types:

```typescript
import type {
  Action,
  Approval,
  ApprovalStatus,
  EndUserConnection,
  Invocation,
  InvocationStatus,
  InvokeResult,
  ImpactLimit,
} from '@anlyonhq/sdk';

function needsReconcile(invocation: Invocation) {
  return invocation.status === 'unknown';
}
```

Every response is `{ success, data }`, and lists add `pagination`. A successful response also carries `requestId`, read from `X-Request-Id`.

## Page through lists

`actions.invocations`, `actions.effects`, `approvals.list` and `runs.list` take `limit` and `offset`. `endUsers.list` takes a `cursor`.

```typescript
const page = await ops.actions.invocations({ status: 'unknown', limit: 50, offset: 0 });

for (const invocation of page.data ?? []) {
  console.log(invocation.id, invocation.actionName, invocation.endUserId);
}
console.log(page.pagination);
```

## Related

**[Act for your users](/guides/act-for-your-users)**

A complete Next.js and AI SDK app, with connect, approve and receipts per user

**[Quickstart](/quickstart)**

One governed action for one user, end to end

**[Mastra](/guides/mastra)**

Wire an action into a Mastra agent

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

What each grade means and how to read the evidence

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

Who decides, and how a policy routes a call

**[API reference](/api-reference)**

Every endpoint, scope and response

The package is on npm: [@anlyonhq/sdk](https://www.npmjs.com/package/@anlyonhq/sdk).
