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

TypeScript SDK

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

npm install @anlyonhq/sdk
pnpm add @anlyonhq/sdk
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 for creating keys.

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 declares. Every section below takes one part of this loop further.

Configure the client

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.

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:

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

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.

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:

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.

Handle a user who has not connected

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

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

// 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:

// 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.

List and erase users

// 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.

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 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.
  • 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.
Change what an action declares
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
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

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:

{
  "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.
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, previewId binds it to a reviewed preview, 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.
// 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.

Preview a governed call before it runs
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.

Settle an unknown outcome (operator key)
// 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.
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.

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.

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.

Wait for an approval

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

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.

Ask a person directly
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)
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)
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.

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.

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 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 and 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.

// 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.

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.

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

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.

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 and 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.

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:

// 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 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.

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.
FeatureNotAvailableError 402 FEATURE_NOT_AVAILABLE: the plan does not include the feature. See 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. 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:

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.

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);

The package is on npm: @anlyonhq/sdk.

Was this page helpful?