Integration guide

How to authorize Claude tool calls

Anthropic exposes a tool_use API that makes Claude agents capable of high-impact tool calls. The thing the API does not give you is a policy layer between Claude proposing a tool call and your code running it. That gap is where production agents leak, exfiltrate, and act on jailbroken instructions. Wire the Anthropic TypeScript SDK to Veto so each tool_use block runs through a YAML-backed decision before the tool executes. Four steps: declare the tools, write the policy, wrap the loop, return policy outcomes as tool_result blocks so Claude can keep the conversation moving.

  • Anthropic SDK message loop that handles tool_use stop reasons cleanly.
  • A YAML policy that names the same tools Claude knows about.
  • Per-tool_use decision via veto.decide with agent, args, and tenant context.
  • Deny and require_approval outcomes surfaced back to Claude as tool_result blocks so the next turn adapts.

Step 1: Define tools for Claude and Veto

Declare each tool to Claude in the SDK's input schema and reference the same name in your YAML policy. The key alignment is the tool name and the argument shape. Claude proposes a tool_use with the name; Veto looks up the matching rule set by the same name.

ts
// npm install @anthropic-ai/sdk veto-sdk
import Anthropic from "@anthropic-ai/sdk"
import { Veto } from "veto-sdk"

const anthropic = new Anthropic()
const veto = new Veto({ apiKey: process.env.VETO_API_KEY })

const TOOLS = [
  {
    name: "search_orders",
    description: "Search the orders table by user_id or status.",
    input_schema: {
      type: "object",
      properties: {
        user_id: { type: "string" },
        status: { type: "string", enum: ["paid", "refunded", "shipped"] },
        limit: { type: "integer", maximum: 100 },
      },
      required: [],
    },
  },
  {
    name: "refund_order",
    description: "Issue a refund for an order.",
    input_schema: {
      type: "object",
      properties: {
        order_id: { type: "string" },
        amount_cents: { type: "integer" },
      },
      required: ["order_id", "amount_cents"],
    },
  },
]

Keep the tool list small. Five well-scoped tools beat fifteen overlapping ones for both the model's reasoning and your policy surface.

Step 2: Write the policy

The YAML mirrors the tool list. Each rule block names the tool, then expresses the conditions under which a call is allowed, denied, or escalated. Below, search is bounded by org and limit; refunds above a threshold require approval.

yaml
# policies/claude-agent.yaml
- name: search_orders_within_org
  match:
    tool: search_orders
  rules:
    - if: args.limit > 100
      then: deny
    - if: args.user_id != null and not is_in_org(args.user_id, context.tenant_id)
      then: deny
    - then: allow

- name: refunds_gated_by_amount
  match:
    tool: refund_order
  rules:
    - if: args.amount_cents > 50000
      then: require_approval
    - then: allow

The is_in_org function above is a custom predicate you register on the SDK. See the YAML reference for the full predicate API.

Step 3: Wrap each tool_use block

In the message loop, when Claude returnsstop_reason: "tool_use", filter the content blocks to extract the tool_use entries. For each one, callveto.decide. On allow, run the tool. On deny, return the reason as a tool_result with is_error: true. On require_approval, block on veto.approvals.waituntil a human acts.

ts
async function runAgent(userMessage: string, agentId: string, tenantId: string) {
  const messages: Anthropic.MessageParam[] = [
    { role: "user", content: userMessage },
  ]

  while (true) {
    const response = await anthropic.messages.create({
      model: process.env.ANTHROPIC_MODEL!,
      max_tokens: 1024,
      tools: TOOLS,
      messages,
    })

    if (response.stop_reason !== "tool_use") {
      return response
    }

    const toolUses = response.content.filter(
      (block): block is Anthropic.ToolUseBlock => block.type === "tool_use",
    )

    const toolResults: Anthropic.ToolResultBlockParam[] = []
    for (const tool of toolUses) {
      const decision = await veto.decide({
        tool: tool.name,
        args: tool.input as Record<string, unknown>,
        agent: { id: agentId, role: "support" },
        context: { tenant_id: tenantId, source: "claude_assistant" },
      })

      if (decision.outcome === "deny") {
        toolResults.push({
          type: "tool_result",
          tool_use_id: tool.id,
          content: `Blocked by policy: ${decision.reason}`,
          is_error: true,
        })
        continue
      }

      if (decision.outcome === "require_approval") {
        const approval = await veto.approvals.wait(decision.approvalId, {
          timeout: 300,
        })
        if (approval.status !== "approved") {
          toolResults.push({
            type: "tool_result",
            tool_use_id: tool.id,
            content: `Approval rejected: ${approval.note}`,
            is_error: true,
          })
          continue
        }
      }

      const result = await runTool(tool.name, tool.input)
      toolResults.push({
        type: "tool_result",
        tool_use_id: tool.id,
        content: JSON.stringify(result),
      })
    }

    messages.push({ role: "assistant", content: response.content })
    messages.push({ role: "user", content: toolResults })
  }
}

The pattern that matters: append all tool_results in a single user-role message after the assistant-role response. Claude expects the round-trip to be one message per side. Mixing them breaks the conversation.

Step 4: Stream variant for low-latency agents

If you stream Claude's output to the user, hook the toolUse event on the stream and run the decision before any side effect lands. On deny, abort the stream. The pattern stays the same; the integration point is one event handler.

ts
import { Anthropic } from "@anthropic-ai/sdk"

const stream = anthropic.messages.stream({
  model: process.env.ANTHROPIC_MODEL!,
  max_tokens: 1024,
  tools: TOOLS,
  messages,
})

stream.on("toolUse", async (toolUse) => {
  const decision = await veto.decide({
    tool: toolUse.name,
    args: toolUse.input as Record<string, unknown>,
    agent: { id: agentId, role: "support" },
    context: { tenant_id: tenantId, source: "claude_assistant" },
  })

  if (decision.outcome === "deny") {
    stream.controller.abort()
    return
  }
})

For full client-side wiring including Claude Desktop and Claude Code, see /integrations/claude.

Failure modes to catch

Returning a deny as an exception

Throwing breaks the agent loop. Return the deny reason as a tool_result block with is_error true. Claude reads it and adapts the next turn, which is usually what you want.

No tenant context

Pass tenant_id on every decide call, even for single-tenant deployments. The field becomes the boundary you reach for when isolation requirements grow.

Drift between tool schema and policy

If you add an argument to the Anthropic input_schema and forget to update the YAML, the decision passes through unchecked. Keep the tool schema and the policy in the same review.

Production checklist

  • Tool list in the SDK and rule blocks in YAML have a 1:1 name mapping.
  • Each tool_use block runs through veto.decide before execution.
  • Deny and approval-rejected outcomes return as tool_result blocks, not exceptions.
  • tenant_id and source are on every decide call.
  • Streaming agents abort on deny instead of running the tool and rolling back.

FAQ

Does this work with the Claude streaming API?

Yes. Subscribe to the toolUse event on the stream, run veto.decide, and abort or substitute the result based on the outcome. The streaming variant is in step 4 of the guide.

What about Claude Code, the CLI?

Claude Code talks to Anthropic the same way the SDK does, so the same policy applies. The cleanest way to enforce against Claude Code is to put Veto in front of the MCP servers it uses; see the MCP tool policies guide for the gateway pattern.

Can Veto block Claude before the tool definition gets sent?

The decision happens after Claude requests a tool_use but before you execute the tool. That is the right point: it lets the model propose anything while the policy decides what runs. If you want to restrict which tools Claude can even see, filter the TOOLS array before passing it to the SDK.

Related guides

Put policy between Claude and your production tools.