TypeScript SDK
Official TypeScript SDK for Anlyon. Define actions, vault secrets, invoke by name, wait for approvals, read outcomes and verify webhooks from a typed client.
The Anlyon TypeScript SDK is a typed client for the Anlyon API. It covers the governed path: actions, secrets, approvals, runs and webhook verification.
Installation
Install the SDK using your preferred package manager:
npm install @anlyonhq/sdkpnpm add @anlyonhq/sdkyarn add @anlyonhq/sdkQuick Start
Two clients, two keys. The operator key defines the action and puts the credential in the vault. The agent key invokes and holds nothing else.
import { Client } from '@anlyonhq/sdk';
// Operator: run once. The only place the credential appears.
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'] });
await ops.actions.create({
name: 'refund-order',
description: 'Refund a Stripe charge.',
method: 'POST',
urlTemplate: 'https://api.stripe.com/v1/refunds',
headers: { Authorization: 'Bearer {{secret:STRIPE_KEY}}', 'Content-Type': 'application/x-www-form-urlencoded' },
inputSchema: {
type: 'object',
properties: { charge: { type: 'string' }, amount: { type: 'integer' } },
required: ['charge', 'amount'],
},
requiresApproval: true,
});
// Agent: no URL, no header, no key.
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) {
const decided = await anlyon.approvals.waitForDecision(data!.approvalId!);
console.log('Decision:', decided.status);
} else {
console.log('Stripe replied', data!.responseStatus);
}
Configuration
Basic Configuration
const client = new Client({
apiKey: process.env.ANLYON_API_KEY!,
});
Advanced Configuration
const client = new Client({
apiKey: process.env.ANLYON_API_KEY!,
baseUrl: 'https://api.anlyon.com', // Optional: defaults to production
timeout: 30000, // Request timeout in milliseconds
retry: {
maxAttempts: 5,
initialDelay: 100,
maxDelay: 10000,
backoffMultiplier: 2,
},
logging: {
enabled: true,
level: 'info', // 'debug' | 'info' | 'warn' | 'error'
},
});
The retry block governs the SDK’s own transport retries. The SDK sends a request again after a network error, a timeout, a 429 or a 5xx, up to three attempts by default. That applies to actions.invoke too.
From @anlyonhq/sdk 2.2.0 the SDK sends an idempotency key on every invoke and generates one when you do not pass one. It reuses that key across its own transport retries. A resend is answered from the recorded invocation. Earlier versions send a key only when you pass one, so pass one.
A generated key covers one call to invoke. When your own code can call invoke twice for one business operation, pass your own idempotencyKey derived from that operation.
This is not an action retry. An invocation whose outcome cannot be confirmed is recorded as unknown, and Anlyon does not send it to the destination again. See Idempotency and retries.
Actions
Define a production call once, then let the agent invoke it by name. The definition carries the URL, headers and input schema, and references secrets as {{secret:NAME}}.
Define an action
await ops.actions.create({
name: 'update-ticket-priority',
method: 'PATCH',
urlTemplate: 'https://api.example.com/tickets/{{input.ticketId}}',
headers: { Authorization: 'Bearer {{secret:HELPDESK_TOKEN}}' },
inputSchema: {
type: 'object',
properties: { ticketId: { type: 'string' }, priority: { type: 'string' } },
required: ['ticketId', 'priority'],
},
requiresApproval: false,
});Invoke an action
const { data } = await anlyon.actions.invoke(
'update-ticket-priority',
{ ticketId: 'T-1042', priority: 'high' },
{ idempotencyKey: 'ticket-T-1042-priority-high' },
);
console.log(data!.status, data!.responseStatus);Pass runId in the options to attach the invocation to a run’s trace. Reusing the same idempotencyKey returns the recorded result instead of making the call again.
List actions and versions
const { data: actions } = await anlyon.actions.list();
const { data: versions } = await ops.actions.versions('refund-order');Read invocations and outcomes
// One invocation, with the destination's reply and the version that ran
const { data: invocation } = await anlyon.actions.invocation('inv_...');
// Everything waiting for an operator to confirm what happened
const unknown = await ops.actions.invocations({ status: 'unknown' });
// Record what the destination confirms. Needs actions:resolve, and the key
// that made the call can never resolve it.
await ops.actions.resolveInvocation('inv_...', {
outcome: 'succeeded',
evidence: 'Refund re_1 visible in the Stripe dashboard at 14:02 UTC.',
externalReference: 're_1',
});Governed actions
An action can declare how much one call changes and how to read the result back. Either declaration makes the action governed. Its impact is reserved against shared limits before dispatch, and every call returns a receipt grade. A governed action needs a fixed https host.
Declare impact and a read-back
await ops.actions.declare('send-email', {
// Counted before dispatch. `emails` is a unit this workspace declares.
impact: { dimension: 'emails', amount: 'count(input.to)', bound: 'exact' },
// A GET on the action's own host, issued after the write.
verify: {
url: 'https://api.example.com/v1/messages/{{response.id}}',
status: 200,
match: [{ path: 'status', equals: 'sent' }],
},
});
// One cap shared by every agent, session and key in the environment.
await ops.impactLimits.create({ name: 'Daily emails', dimension: 'emails', period: 'day', amount: 500 });
await ops.actions.declare('send-email', { verify: null }); // remove the read-back
await ops.actions.declare('send-email', { governed: false }); // remove every declarationdeclare() changes these fields and nothing else. A field you leave out keeps what the action declares, and null removes one declaration. actions.create() and actions.update() take the same impact, verify and governed fields. Changing what a governed action already declares needs actions:govern.
For an API you declare, you write what the action counts, and Anlyon enforces it before dispatch.
Read the grade
const { data } = await anlyon.actions.invoke('send-email', { to: ['a@example.com'] });
console.log(data!.grade); // 'confirmed' when the read-back matched
// One effect, with its evidence
const { data: effect } = await ops.actions.effect(data!.effectId!);
console.log(effect!.grade, effect!.outcome, effect!.verification);The grades are confirmed, acknowledged, unknown, failed, refused, denied and pending. grade is on invoke responses and single invocation reads. It is absent when the action is not governed.
Filter effects by grade
// Receipts that nothing read back
const { data: unread } = await ops.actions.effects({ grade: 'acknowledged' });
// Receipts waiting on evidence or an operator
const { data: open } = await ops.actions.effects({ grade: 'unknown' });effects() also filters by action, outcome, stage and unresolved.
Secrets
The vault is write-only. Values go in and are never returned. An action uses a secret by name, and the agent that invokes the action never sees it.
await ops.secrets.put('STRIPE_KEY', {
value: process.env.STRIPE_KEY!,
allowedHosts: ['api.stripe.com'],
allowedPlacements: ['header'],
});
// Names and metadata only, never values
const { data: secrets } = await ops.secrets.list();
await ops.secrets.delete('OLD_TOKEN');
Names are uppercase (^[A-Z0-9_]+$). Rotating a secret without allowedHosts leaves the existing host list alone, so a rotation never widens where a credential may go.
Approvals
Gated actions create approvals on their own. An agent can also ask its human directly. Deciding needs the separate approvals:decide scope, and a key can never decide an approval it requested itself.
Request a decision
const { data: approval } = await anlyon.approvals.request({
title: 'Send the contract to Acme?',
description: 'Draft is ready. Sending is irreversible.',
payload: { draftId: 'doc_9' },
});
console.log(approval!.id); // apr_...Wait for the decision
const decided = await anlyon.approvals.waitForDecision(approval!.id, {
timeoutMs: 10 * 60_000,
});
if (decided.status === 'approved') await send();
else if (decided.status === 'pending') console.log('Still waiting. Ask again later.');
else console.log('Not sent:', decided.status);A timeout is not an error: the approval comes back still pending, so always branch on status.
Decide (operator key)
const operator = new Client({ apiKey: process.env.ANLYON_OPERATOR_KEY! });
// The inbox
const pending = await operator.approvals.list({ status: 'pending' });
// Approving a gated action also runs the parked invocation
const { data } = await operator.approvals.approve('apr_...', { note: 'Checked the order.' });
console.log(data!.invocation?.status);
await operator.approvals.deny('apr_...', { note: 'Wrong customer.' });Local gate (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 a human approvesThe wrapper waits for a decision before calling your function. Your process still runs your function with your credential, so this gives you the decision and the record, not credential isolation. See Local approval gates.
Runs
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: 'refund-triage', agentId: 'agt_123' });
await anlyon.actions.invoke('refund-order', { charge: 'ch_3P9x', amount: 12000 }, { runId: run!.id });
await anlyon.runs.end(run!.id, { status: 'succeeded', output: { refunded: 1 } });
// The run with its span tree
const { data: trace } = await anlyon.runs.get(run!.id);
// Redacted OpenTelemetry Protocol JSON
const { data: otlp } = await anlyon.runs.exportTrace(run!.id);
Webhook Signature Verification
Subscription deliveries that Anlyon signs carry an Anlyon-Signature header. Verify it before trusting the body. 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.
Using the Receiver Class
import { Receiver } from '@anlyonhq/sdk';
const receiver = new Receiver({
signingSecret: process.env.ANLYON_SIGNING_SECRET!,
});
// Boolean check
const isValid = receiver.verify({
signature: req.headers.get('anlyon-signature'),
body: rawBody,
});
if (!isValid) {
return new Response('Unauthorized', { status: 401 });
}
// Or throw on failure
receiver.verifyOrThrow({ signature, body });
Next.js Integration
The SDK has helpers for the Next.js App Router that verify the signature before your handler runs.
// app/api/anlyon/route.ts
import { serve } from '@anlyonhq/sdk/nextjs';
export const { POST } = serve(async (request) => {
const data = await request.json();
// Process the verified webhook
await handleEvent(data);
return { success: true };
});
For more control over your handler:
// app/api/webhook/route.ts
import { verifySignature } from '@anlyonhq/sdk/nextjs';
export const POST = verifySignature(async (request) => {
const data = await request.json();
// Signature is already verified
await handleWebhook(data);
return Response.json({ ok: true });
});
Set your signing secret in your environment:
ANLYON_SIGNING_SECRET=whsec_your_signing_secret_here
Custom configuration:
export const { POST } = serve(
async (request) => {
const data = await request.json();
return { processed: true };
},
{
signingSecret: process.env.CUSTOM_SECRET, // Override default env var
clockTolerance: 600, // 10 minutes (default: 300)
onError: (error) =>
Response.json({ error: error.message }, { status: 400 }),
}
);
See the Webhooks guide for the event types and payloads.
Error Handling
The SDK provides typed error classes for different error scenarios:
import {
AnlyonError,
ApprovalDeniedError,
ApprovalPendingError,
AuthenticationError,
RateLimitError,
QuotaExceededError,
ValidationError,
NotFoundError,
} from '@anlyonhq/sdk';
try {
await anlyon.actions.invoke('refund-order', { charge: 'ch_3P9x', amount: 12000 });
} catch (error) {
if (error instanceof AuthenticationError) {
console.error('Invalid API key');
} else if (error instanceof RateLimitError) {
console.error(`Rate limit exceeded: ${error.message}`);
} else if (error instanceof QuotaExceededError) {
console.error('Quota exceeded. Wait for the reset or free up room; see error.message.');
} else if (error instanceof ValidationError) {
console.error('Validation error:', error.details);
} else if (error instanceof NotFoundError) {
console.error('Resource not found');
} else {
console.error('Unexpected error:', error);
}
}
ApprovalDeniedError and ApprovalPendingError are thrown by approvals.gate() only. A gated hosted action never throws for a pending decision: it returns pendingApproval: true and you wait or come back later.
TypeScript Types
The SDK is written in TypeScript and exports its request and response types:
import type {
Action,
Approval,
ApprovalStatus,
Invocation,
InvocationStatus,
InvokeResult,
Run,
Secret,
} from '@anlyonhq/sdk';
function describe(invocation: Invocation) {
if (invocation.status === 'unknown') {
console.log('Reconcile with the destination before anyone retries this.');
}
}
Pagination
Paginated lists such as actions.invocations, actions.effects, approvals.list and runs.list take limit and offset and return pagination alongside data:
const page = await ops.actions.invocations({ status: 'unknown', limit: 50, offset: 0 });
for (const invocation of page.data ?? []) {
console.log(invocation.id, invocation.actionName);
}
console.log(page.pagination);

