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

Claude Agent SDK

Give a Claude Agent SDK agent your workspace's actions as MCP tools with an API key, act for one of your users, and have an approver decide gated calls in Anlyon.

An agent built on the Claude Agent SDK (@anthropic-ai/claude-agent-sdk for TypeScript, claude-agent-sdk for Python) runs Claude Code’s tools in your process. Give it a provider key and any of those tools, Bash included, can read it, and a wrong call goes straight out.

Connect it to Anlyon’s MCP server instead. The agent sees your actions as tools. It names an action and passes input; Anlyon adds the credential at dispatch, applies your limits, waits for an approver when a policy says so, and records a receipt. The agent’s process holds an Anlyon API key whose scopes are exactly what it can do, and no provider credential.

This covers calls routed through Anlyon. A tool your own code calls directly is not governed by Anlyon.

The statements about the Claude Agent SDK on this page come from Anthropic’s documentation as checked on 2026-10-27. Check Anthropic’s MCP, permissions and custom tools pages for current behaviour.

Decide where the approval happens

The SDK can pause a tool call for approval: a canUseTool callback on query() receives the tool name and input and returns allow or deny. It runs in your process, and the tool then runs with whatever credential your process holds. Use it when the user in front of the agent is the reviewer.

Use an Anlyon action when the decision belongs to someone else, when you need the decision and the call on record, when a limit must hold across sessions and agents, or when the provider key must stay out of the agent’s process.

Create the keys

The SDK does not open a browser or run an OAuth flow, so connect with an API key. Create two in the console under Settings → API keys:

  • Agent key (ANLYON_AGENT_KEY): actions:read, actions:invoke, approvals:read. This is the key the MCP server acts with. Do not give it approvals:decide.
  • Operator key (ANLYON_OPERATOR_KEY): approvals:decide and actions:read, held by the person or service that reviews. A key can never decide an approval it requested. Add actions:resolve if this key will also record the outcome of an unknown invocation.

Connect the Anlyon MCP server

Pass Anlyon’s hosted server in mcpServers with type: 'http'. The SDK names each MCP tool mcp__<server>__<tool>, where <server> is the key you give it. Every enabled action is an MCP tool named action_<name>, so an action named refund is mcp__anlyon__action_refund here.

// Needs ANTHROPIC_API_KEY and ANLYON_AGENT_KEY.
import { query } from '@anthropic-ai/claude-agent-sdk';

for await (const message of query({
  prompt:
    'Refund payment pi_3P9x for order ord_1042, 24000 cents in usd, with action_refund. ' +
    'If it is parked for approval, call wait_for_approval with the approvalId and report the decision.',
  options: {
    mcpServers: {
      anlyon: {
        type: 'http',
        url: 'https://api.anlyon.com/mcp',
        headers: { Authorization: `Bearer ${process.env.ANLYON_AGENT_KEY}` },
      },
    },
    // The approval happens in Anlyon, so the SDK does not prompt for these two tools.
    allowedTools: ['mcp__anlyon__action_refund', 'mcp__anlyon__wait_for_approval'],
  },
})) {
  if (message.type === 'system' && message.subtype === 'init') {
    const server = message.mcp_servers.find((s) => s.name === 'anlyon');
    if (server?.status === 'failed' || server?.status === 'needs-auth') throw new Error('Anlyon MCP server is not connected. Check ANLYON_AGENT_KEY.');
  }
  if (message.type === 'result' && message.subtype === 'success') console.log(message.result);
}

The same in Python:

import asyncio
import os

from claude_agent_sdk import ClaudeAgentOptions, ResultMessage, query


async def main():
    options = ClaudeAgentOptions(
        mcp_servers={
            "anlyon": {
                "type": "http",
                "url": "https://api.anlyon.com/mcp",
                "headers": {"Authorization": f"Bearer {os.environ['ANLYON_AGENT_KEY']}"},
            }
        },
        allowed_tools=["mcp__anlyon__action_refund", "mcp__anlyon__wait_for_approval"],
    )
    async for message in query(
        prompt="Refund payment pi_3P9x for order ord_1042, 24000 cents in usd, with action_refund. "
        "If it is parked for approval, call wait_for_approval with the approvalId and report the decision.",
        options=options,
    ):
        if isinstance(message, ResultMessage) and message.subtype == "success":
            print(message.result)


asyncio.run(main())

List the Anlyon tools by name in allowedTools. An allowed tool runs without a canUseTool prompt, which would otherwise put a second approval, one Anlyon does not record, in front of the one that counts. mcp__anlyon__* allows every Anlyon tool. Leave a tool out if you want the user in front of the agent to confirm it as well.

To run the MCP server in the agent’s own process tree instead, use the npm package over stdio:

const anlyonServer = {
  command: 'npx',
  args: ['-y', '@anlyonhq/mcp-server'],
  env: { ANLYON_API_KEY: process.env.ANLYON_AGENT_KEY! },
};

Act for one of your users

When the agent acts in one of your users’ accounts, your server names the user. Add the Anlyon-End-User header to the server’s headers, from your own session:

// userId comes from your session, never from the model.
const anlyonForUser = {
  type: 'http' as const,
  url: 'https://api.anlyon.com/mcp',
  headers: {
    Authorization: `Bearer ${process.env.ANLYON_API_KEY}`,
    'Anlyon-End-User': userId,
  },
};

With the stdio server, add '--end-user', userId to args instead. Each call then uses that user’s own connected account, counts toward that user’s limits, and waits for that user’s approval when the policy is decided by the end user. Create the key with Require an end user so a connection without the header is refused (400 END_USER_REQUIRED).

When the user has not connected the integration, the tool returns a result, not an error: { "status": "connect_required", "integration": "gmail", "connectUrl": "...", "message": "..." }. Nothing was sent. The agent can pass the message on and your app can show connectUrl as a Connect button. See Act for one of your users.

What happens on a gated call

The action refund in this example uses the stripe.refund adapter with requiresApproval: true. See Governed effects to define it.

  1. The agent calls action_refund. Anlyon records the invocation, creates an approval, and returns Awaiting human approval with its apr_... id. Nothing has been sent to Stripe.
  2. The agent calls wait_for_approval, which holds the call open (60 seconds by default, 120 at most) until an approver decides. If it comes back still pending, the agent calls it again.
  3. An approver approves in the Anlyon inbox, in Slack, or with the operator key:
curl -sS -X POST "${ANLYON_BASE_URL:-https://api.anlyon.com}/api/v2/approvals/$APPROVAL_ID/approve" \
  -H "Authorization: Bearer $ANLYON_OPERATOR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"note": "Checked the order; refund is correct."}'
  1. The approver approves the exact change, and that exact change is what runs. The approval is bound to a digest of the request, target, versions, preconditions and expiry, and Anlyon recomputes it before dispatch and refuses on any difference. Stripe has no compare-and-set, so the refundable amount shown is as of review.
  2. Anlyon reserves the refund against any impact limit that applies, attaches the Stripe key from the vault, and sends the refund. Every agent, session and key in the environment draws from the same limit. A new session or key does not get a new allowance.
  3. wait_for_approval returns the approval record. The agent reads the decision (approved, denied or expired) and who made it. payload.invocationId names the invocation it gated. A denied or expired approval comes back with that status, and the gated request is never sent.

Read the outcome

The approval says who decided. The invocation says what happened when Anlyon made the request, and for a governed action it carries a receipt grade. Read it with any key that has actions:read:

curl -sS "${ANLYON_BASE_URL:-https://api.anlyon.com}/api/v2/actions/invocations/$INVOCATION_ID" \
  -H "Authorization: Bearer $ANLYON_OPERATOR_KEY"
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.

For stripe.refund, every success is read from Stripe’s own record of the refund. See Receipts and grades and When the outcome is unknown.

Call Anlyon from a custom tool instead

To control the wait and the text the agent reads, define your own tool with the SDK’s tool() and createSdkMcpServer() and call the Anlyon SDK from its handler:

import { tool, createSdkMcpServer } from '@anthropic-ai/claude-agent-sdk';
import { Client } from '@anlyonhq/sdk';
import { z } from 'zod';

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

const refundOrder = tool(
  'refund_order',
  'Refund a Stripe payment. Amount is in cents. Every refund waits for an approver.',
  { orderId: z.string(), paymentIntent: z.string(), amount: z.number().int() },
  async ({ orderId, paymentIntent, amount }) => {
    const { data } = await anlyon.actions.invoke(
      'refund',
      { paymentIntent, amount, currency: 'usd' },
      { operationKey: `refund:${orderId}` },
    );
    const text = data!.pendingApproval
      ? `Waiting for an approver (${data!.approvalId}). Do not retry.`
      : `Status ${data!.status}, grade ${data!.grade}.`;
    return { content: [{ type: 'text', text }] };
  },
);

export const billing = createSdkMcpServer({ name: 'billing', version: '1.0.0', tools: [refundOrder] });

Pass it as mcpServers: { billing } and allow mcp__billing__refund_order. The handler returns as soon as the approval is created. Approving it is what dispatches, so the agent does not have to be running when the approver decides. To act for a user here, build the client with anlyon.forUser(userId); it throws EndUserNotConnectedError with a connectUrl when the user has not connected.

Fix connection errors

What you see Why What to do
The init message shows anlyon as needs-auth or failed The key was refused (401), or ANLYON_AGENT_KEY is empty Check the key. The run continues without Anlyon’s tools, so stop it as the example does
The agent sees the tools but does not call them The tools are not in allowedTools Add mcp__anlyon__<tool> names, or mcp__anlyon__*
API key is missing required scope: actions:invoke The key lacks that scope Add the scope to the key
400 END_USER_REQUIRED The key requires an end user and no Anlyon-End-User was sent Add the header
400 INVALID_END_USER The id has characters outside letters, digits and . _ : @ -, or is over 128 characters Use your own user id in that form
"status": "connect_required" The end user has not connected the integration. Nothing was sent Send the user to connectUrl

Credentials

The model never sees a provider credential: an action references {{secret:NAME}} or a connection, and Anlyon resolves it at dispatch. A secret is bound to a destination and a placement, checked again at fire time.

The agent’s process still holds the Anlyon agent key and your Anthropic key. The SDK runs Claude Code’s built-in tools, including Bash and Read, unless you remove them with the tools or disallowedTools options. A key in the agent’s environment is reachable by those tools, so keep provider keys in the vault and give the agent key only the scopes above.

More than one approver

To require more than one decision, set an approval policy on the action. N counts distinct credentials for API-key deciders and distinct users for dashboard and OAuth deciders: two keys held by one person are two deciders.

Was this page helpful?