Two agents, one limit, one refused.
All examples
OpenAI Agents SDK
Updated
5 min read
by Anlyon Team

OpenAI Agents SDK: tool calls with human approval, without giving the agent your API key

A runnable TypeScript example: an OpenAI Agents SDK tool that issues Stripe refunds through an Anlyon action. The agent never holds the Stripe key, large refunds wait for a human, and every call is recorded.

openai agents sdktypescriptapprovalsstripetool calling

This example builds a support agent with the OpenAI Agents SDK that can refund Stripe charges. Three properties hold without relying on the model's behaviour:

  • The agent never holds the Stripe key. It asks for a named Anlyon action. Anlyon attaches the key and makes the call.
  • Large refunds wait for a person. A policy decides on every call, outside your process.
  • Every call is recorded: who asked, which policy or approver decided, and what was sent.

Written against @openai/agents and @anlyonhq/sdk 2.1. Use a Stripe test key.

How it fits together

OpenAI Agents SDK run
  -> tool "refund_order" (your process, holds an Anlyon key with actions:invoke only)
    -> anlyon.actions.invoke("refund-order", input)
      -> policy: small refunds auto-approved, large ones wait for a person
        -> Anlyon resolves {{secret:STRIPE_KEY}} and calls api.stripe.com

1. One-time setup, with an operator key

Run this from a deploy script, not from the agent. It stores the Stripe key, defines the action, and adds the policies.

// setup.ts
import { Client } from '@anlyonhq/sdk';

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

await ops.secrets.put('STRIPE_KEY', { value: process.env.STRIPE_TEST_KEY! });

await ops.actions.create({
  name: 'refund-order',
  description: 'Refund a Stripe charge, in full or in part. Amount is in cents.',
  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', pattern: '^ch_' },
      amount: { type: 'integer' },
    },
    required: ['charge', 'amount'],
  },
});

// Lowest priority number first; the first match wins.
await ops.approvalPolicies.create({
  name: 'refunds-over-100-need-a-person',
  matchKind: 'action',
  matchActionName: 'refund-order',
  minAmount: 10000, // $100.00 and up
  effect: 'require_approval',
  priority: 10,
});

await ops.approvalPolicies.create({
  name: 'small-refunds-auto',
  matchKind: 'action',
  matchActionName: 'refund-order',
  effect: 'auto_approve',
  priority: 20,
});

2. The tool

The tool's job is to invoke the action and tell the model, in plain words, what happened. It never touches Stripe directly.

// refund-tool.ts
import { tool } from '@openai/agents';
import { Client } from '@anlyonhq/sdk';
import { z } from 'zod';

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

// Fail at startup if the agent's key can do more than ask.
await anlyon.auth.requireScopes(['actions:invoke'], {
  forbidden: ['actions:write', 'secrets:read', 'secrets:write', 'approvals:decide'],
});

/** After a decision, the invocation runs; wait for its final status. */
async function settle(invocationId: string) {
  for (let attempt = 0; attempt < 30; attempt++) {
    const { data } = await anlyon.actions.invocation(invocationId);
    if (data!.status !== 'pending_approval' && data!.status !== 'running') return data!;
    await new Promise((resolve) => setTimeout(resolve, 1000));
  }
  return null;
}

export const refundOrder = tool({
  name: 'refund_order',
  description:
    'Refund a Stripe charge. Amount is in cents. Refunds of $100 or more wait for a human to approve.',
  parameters: z.object({
    orderId: z.string().describe('Your order id, used to make the refund idempotent.'),
    charge: z.string().describe('Stripe charge id, starting with ch_.'),
    amount: z.number().int().describe('Amount to refund, in cents.'),
  }),
  execute: async ({ orderId, charge, amount }) => {
    const { data } = await anlyon.actions.invoke(
      'refund-order',
      { charge, amount },
      { idempotencyKey: `refund:${orderId}` },
    );
    let invocation = data!;

    if (invocation.pendingApproval) {
      const decided = await anlyon.approvals.waitForDecision(invocation.approvalId!, {
        timeoutMs: 120_000,
      });
      if (decided.status === 'pending') {
        return `The refund is waiting for a human to approve it (approval ${decided.id}). Tell the customer it is being reviewed. Do not retry.`;
      }
      if (decided.status !== 'approved') {
        const why = decided.status === 'denied' ? 'A human denied the refund' : 'The approval expired';
        return `${why}. Do not retry. Tell the customer it was not approved.`;
      }
      const settled = await settle(invocation.id);
      if (!settled) {
        return `The refund was approved and is still being processed (invocation ${invocation.id}). Do not retry.`;
      }
      invocation = { ...settled, pendingApproval: false };
    }

    switch (invocation.status) {
      case 'succeeded':
        return `Refund issued for ${amount} cents on ${charge}.`;
      case 'unknown':
        return 'The refund may already have been issued. Nothing checks it automatically, so a person must check Stripe. Do not retry.';
      default:
        return `The refund was not issued: ${invocation.decision?.explanation ?? invocation.error ?? 'unknown reason'}.`;
    }
  },
});

Three details that matter:

  • The idempotency key comes from the order, so if the model calls the tool twice for the same order, the second call replays the first instead of refunding again.
  • Every return value tells the model whether to retry. An agent that reads "error" will reasonably try again, and one that reads "do not retry" will not.
  • unknown is not failed. If Stripe's response was lost, the refund may have happened. The tool says so instead of inviting a retry.

3. The agent

// agent.ts
import { Agent, run } from '@openai/agents';
import { refundOrder } from './refund-tool';

const agent = new Agent({
  name: 'Support agent',
  instructions:
    'You help customers with orders. When a refund is justified, use refund_order. Report the outcome exactly as the tool describes it.',
  tools: [refundOrder],
});

const result = await run(
  agent,
  'Order ord_1042 arrived broken. Please refund charge ch_3P9x in full: $240.00.',
);

console.log(result.finalOutput);

A $240 refund matches the first policy, so the tool waits. Approve it in the Anlyon console under Approvals, and the run continues: Anlyon sends the request the approver saw, and the tool reports the outcome.

Where needsApproval fits

As of 2026-09-30, the Agents SDK's own human-in-the-loop support pauses the run when a tool has needsApproval, and your code approves or rejects the interruption. It is useful for confirming with the user who is talking to the agent.

It is a different control from the one above. As of 2026-09-30, needsApproval runs in your process. It holds while your code goes through it, and it does not move a credential. The Anlyon policy is evaluated on Anlyon's side of the call. In this example the Stripe key is in the Anlyon vault and not in your process. Use both if you want both a conversational confirmation and an enforced decision.

Free during Early Beta Access, no credit card. Start building →

Frequently asked questions

How do I add human approval to a tool in the OpenAI Agents SDK?

As of 2026-09-30, the SDK has needsApproval on tool(), which pauses the run and surfaces an interruption your code approves or rejects. That approval lives in your process and your run state. For a decision enforced outside your process, route the call through an Anlyon action with an approval policy: the tool invokes the action, and Anlyon holds the request until a person decides.

Can I use needsApproval and Anlyon approvals together?

Yes. needsApproval is a good place for a quick in-conversation confirmation from the user in front of the agent. The Anlyon policy still applies if that code path is skipped, because this action's request to Stripe is made by Anlyon. That holds while no other copy of the Stripe key is in your process.

Does the agent see the Stripe key in this example?

No. The key is stored in the Anlyon vault and referenced as {{secret:STRIPE_KEY}} in the action definition. The agent's process holds only an Anlyon key with actions:invoke, which can ask for the refund-order action but cannot read the vault or change the action.

Free tier, no credit card. One command if you use Claude or Cursor.

$ claude mcp add anlyon -- npx -y @anlyonhq/mcp-server