Back to Blog
Engineering

Claude Agent Guardrails: Block Unsafe Tool Calls Before They Run

Claude can ask for any tool you give it. governed_tools checks each call first, and a refused call never runs.

Claude agent guardrails for tool use: governed_tools checks every call before it runs. add is approved, an undeclared divide is blocked.

October 30, 202610 min read
Claudegoverned_toolsbeta_toolpre-check
An undeclared divide tool call from a Claude agent reaches the governed_tools gate and is blocked; add is approved
One Claude tool call: divide is blocked and add is approved
TL;DR
  • Install agentrust-py[claude] and wrap the tool functions with governed_tools.
  • Wrap the plain function first, then pass it to beta_tool. Not the other way round.
  • Declare add and multiply. Leave divide off the manifest.
  • Run --without-agentrust, then --local, then --console. Add --scripted for no model and no key.
Keep reading for the problem, how the check works, and the full walkthrough →

The governed_tools helper checks every Claude tool call with AgentTrust before your function runs. Wrap your tool functions once, then run a small calculator agent with and without AgentTrust and watch a call get refused.

Claude asks. The gateway answers. Only then does your function run.
THE PROBLEM

The problem: Claude can ask for any tool you hand it

With the Anthropic Messages API, Claude chooses which tool to call and with what arguments, and your code runs it. In this guide's calculator agent, add and multiply are the intended tools, yet Claude can also ask for divide. Run the loop with no check and divide runs: nothing stopped it. For a calculator that is harmless. Swap divide for issue_refund or delete_user and it would run just the same.

Without a check divide runs though it is not declared. With governed_tools add is approved, divide is blocked and the run still finishes
The same agent, without and with the check.
THE FIX

The fix: governed_tools checks each call before the function runs

The AgentTrust helper governed_tools wraps each tool function so the call goes to AgentTrust first. By the end of this guide:

  • Every tool call is checked before it runs, and its result after.
  • A refused call never runs. Claude gets Blocked by AgentTrust: <reason> (envelope ...) as the tool result and the run continues.
  • Every decision, allowed or refused, shows under Events in your console.

Prerequisites. Python 3.10 or later and the anthropic package, version 2 (anthropic>=2.0,<3.0). The walkthrough needs an Anthropic key (ANTHROPIC_API_KEY) unless you add --scripted, which replays a fixed conversation and needs no model and no key. The local gateway needs no account; the console route does.

Package name. Install agentrust-py and import agentrust_sdk. The PyPI package agentrust-sdk belongs to another company.

HOW IT WORKS

Why governed_tools goes under beta_tool

Five steps of one Claude tool call: Claude asks for a tool, the wrapper asks the gateway, the gateway compares and records, then the function runs or is refused
One tool call from request to decision.

Claude only proposes a tool call. Your code runs it, so the check belongs on the function your code runs. beta_tool reads a function's name, docstring and signature to build the tool schema. governed_tools returns functions with all three unchanged, so wrap the plain function first and pass the wrapped one to beta_tool. Applying it to the finished tool object is the wrong order.

What happens on one tool call

  1. Intercept. Claude asks for a tool and your loop calls it. The wrapper receives the call. Your function has not run.
  2. Ask. The wrapper sends the tool name and its arguments, with the agent id, to the AgentTrust gateway. This is the pre-check.
  3. Compare. The gateway checks the call against the agent's declared tools. A tool that is not declared, or an amount above its declared limit, is refused by the tool-trust hard gate.
  4. Record. The gateway writes an event for the decision, allowed or refused.
  5. Run or refuse. An allowed call runs, and its result goes back to the gateway for a post-check, a second event. A refused call never runs. Claude receives Blocked by AgentTrust as the tool result.

What the wrapper must cover

The check covers only the tools you wrap. A tool you hand to Claude unwrapped is not checked, so wrap every tool the agent has. Give each agent its own agent_id so the audit trail says who acted. The manifest matters too: an agent with no declared tools is not restricted, so every tool is allowed.

SET UP

Add governed_tools to your Claude agent

The helper works with a manual tool loop and with beta_tool. These steps connect to your console. With no account, use the local gateway below instead.

Install the adapter

pip install "agentrust-py[claude]"

Register the agent

In your console, select New agent and copy its API Key and Agent ID. The key starts with at_live_ and is shown once. Select the agent, then under Manifest & fingerprint select Declare and add the tools it may use, with an optional limit for a tool that moves money, such as issue_refund up to 100.

Do not skip the manifest.

An agent with no declared tools is not restricted: every tool is allowed.

Point your app at the console

export AGENTRUST_GATEWAY_URL=https://console.agent-trust.tech
export AGENTRUST_KEY=at_live_...
export AGENTRUST_AGENT_ID=support-agent

Wrap the tools

Where: in the one place your tool functions become Claude tools. Wrap the functions first, then pass the wrapped ones to beta_tool. Your Anthropic client does not change.

+ from agentrust_sdk.adapters.claude_agents import governed_tools
- tools = [beta_tool(add), beta_tool(multiply)]
+ tools = [beta_tool(fn) for fn in governed_tools(
+     [add, multiply], agent_id=os.environ["AGENTRUST_AGENT_ID"]
+ )]

Your functions do not change. The wrapped function keeps its name, docstring and signature, so beta_tool builds the same schema for the model. For one function you can also write @beta_tool over @governed_tool(agent_id=...).

When Claude asks for a tool the manifest does not allow, or one over its limit, the tool does not run. Claude receives the reason as the tool result and can explain or try something else. A refund over its declared limit reads like this:

Blocked by AgentTrust: Tool-trust hard gate: tool_trust: 'issue_refund' amount 5000.0 exceeds declared max_amount 100 (envelope …)

Stop the whole run instead. To raise an error on a block instead of letting Claude read the refusal, pass on_block="raise". To check each reply Claude writes too, use ClaudeAgentGuard(agent_id=...).wrap_client(Anthropic()), or wrap_async_client for AsyncAnthropic. It raises BlockedError on a blocked reply. It is separate from the tool wrapper, and it does not cover messages.stream.

No account? Connect to a local gateway

Skip the console steps and start a gateway inside your program before the agent's first tool call:

pip install "agentrust-py[claude,embedded]"
from agentrust_sdk.embedded import embed_gateway

gateway = embed_gateway()
gateway.register_manifest("support-agent", {
    "declared_tools": [
        {"name": "lookup_order"},
        {"name": "issue_refund", "max_amount": 100},
    ],
})

Set AGENTRUST_AGENT_ID=support-agent (or pass the ID straight to governed_tools). Decisions last only while the program runs, so use this to try AgentTrust, in tests and in CI.

SEE IT BLOCKED

Run it and watch a Claude tool call get blocked

The same steps, applied to a small calculator agent. You run it three ways and watch a tool call get refused. About 10 minutes. Steps 1 to 4 need nothing else when you add --scripted to each command; without it they need ANTHROPIC_API_KEY. Steps 5 to 7 use an AgentTrust console account.

Create the project

mkdir agentrust-quickstart && cd agentrust-quickstart
python -m venv .venv && source .venv/bin/activate     # on Windows: .venv\Scripts\activate
pip install "agentrust-py[claude,embedded]"

Save the program below as claude_quickstart.py. It is the example examples/claude_quickstart.py from the AgentTrust SDK repository.

"""
claude_quickstart.py — a Claude agent whose tool calls AgentTrust checks.

The agent is a calculator: Claude (Anthropic Messages API) with add, multiply
and divide tools, asked to do two sums. The loop is the usual one: send the
messages, run each tool Claude asks for, send the results back.

AgentTrust is one line (marked "AgentTrust" below): the tools are wrapped with
governed_tools before they are turned into Claude tools. Every call is checked
before it runs, and its result after. A tool the agent may not use is refused
before its code runs; Claude is told why as the tool result and the run still
finishes. The agent may use add and multiply, not divide.

RUN IT (in the folder that holds this file)
    pip install "agentrust-py[claude,embedded]"
    export ANTHROPIC_API_KEY=...                          # or use --scripted, which needs no key

    python claude_quickstart.py --without-agentrust       # AgentTrust off: every tool runs
    python claude_quickstart.py --local                   # a gateway starts inside this program; no account
    python claude_quickstart.py --console                 # your AgentTrust console keeps every decision
    python claude_quickstart.py --scripted                # replay a fixed conversation instead of calling a model

  --console needs three settings, from the agent you create in the console:
      export AGENTRUST_GATEWAY_URL=https://console.agent-trust.tech
      export AGENTRUST_KEY=at_live_...
      export AGENTRUST_AGENT_ID=...

ANOTHER MODEL
    CLAUDE_QUICKSTART_MODEL=claude-opus-5-5 (export ANTHROPIC_API_KEY).
    Any Claude model that supports tool use works.
"""
from __future__ import annotations

import argparse
import os
import sys
import tempfile
from types import SimpleNamespace

from anthropic import Anthropic, beta_tool

from agentrust_sdk.adapters.claude_agents import governed_tools

AGENT_ID = os.environ.get("AGENTRUST_AGENT_ID", "calculator-agent")
MODEL = os.environ.get("CLAUDE_QUICKSTART_MODEL", "claude-sonnet-5-5")

# The tools this agent may call. With --local they are set here; with --console
# you declare the same list on the agent in the console. divide is left out on purpose.
MANIFEST = {"declared_tools": [{"name": "add"}, {"name": "multiply"}]}

SYSTEM = (
    "You do the arithmetic you are asked for, using your tools, one tool call at a time. "
    "If a tool is refused, say the operation could not be done."
)


def multiply(a: int, b: int) -> int:
    """Multiply `a` and `b`."""
    return a * b


def add(a: int, b: int) -> int:
    """Add `a` and `b`."""
    return a + b


def divide(a: int, b: int) -> float:
    """Divide `a` by `b`."""
    return a / b


# AgentTrust: every call to these tools is checked before it runs and after it
# returns. Turn the wrapped functions into Claude tools, not the plain ones.
tools = [beta_tool(fn) for fn in governed_tools([add, multiply, divide], agent_id=AGENT_ID)]

REQUESTS = ["Add 3 and 4.", "Divide 10 by 2."]


def ask(request: str, client, max_turns: int = 6) -> str:
    """The agent loop: run the tools Claude asks for until it answers in text."""
    by_name = {tool.name: tool for tool in tools}
    messages = [{"role": "user", "content": request}]
    for _ in range(max_turns):
        reply = client.messages.create(
            model=MODEL, max_tokens=1024, system=SYSTEM, messages=messages,
            tools=[tool.to_dict() for tool in tools],
        )
        messages.append({"role": "assistant", "content": reply.content})
        calls = [block for block in reply.content if block.type == "tool_use"]
        if not calls:
            return "".join(block.text for block in reply.content if block.type == "text")
        results = []
        for call in calls:
            # A refused call returns the refusal text here, and Claude reads it next turn.
            output = by_name[call.name].call(call.input)
            print(f"  tool {call.name}({', '.join(f'{k}={v}' for k, v in call.input.items())}) -> {output}")
            results.append({"type": "tool_result", "tool_use_id": call.id, "content": str(output)})
        messages.append({"role": "user", "content": results})
    return "(stopped: too many turns)"


# --scripted: replay a fixed conversation, so the quickstart runs with no model and no key.
# A replay does not read tool results, so its last line is the same with AgentTrust on or off;
# what differs is what each tool returned.
# Each reply is either a tool call (name, arguments) or the final text.
SCRIPT = [
    ("add", {"a": 3, "b": 4}), "3 + 4 = 7.",
    ("divide", {"a": 10, "b": 2}), "That is everything you asked for.",
]


class ScriptedClient:
    """Stands in for Anthropic(): messages.create returns the next scripted reply."""

    def __init__(self, replies=SCRIPT) -> None:
        self._replies = list(replies)
        self.messages = SimpleNamespace(create=self._create)

    def _create(self, **kwargs):
        reply = self._replies.pop(0)
        if isinstance(reply, str):
            block = SimpleNamespace(type="text", text=reply)
        else:
            name, args = reply
            block = SimpleNamespace(type="tool_use", id=f"toolu_{len(self._replies)}", name=name, input=args)
        return SimpleNamespace(content=[block], stop_reason="end_turn")


def ask_agent(where: str, scripted: bool) -> bool:
    """Ask the agent to add, then to divide. False if the model can't be reached."""
    print(f"AgentTrust: {where} · agent {AGENT_ID} · model {'scripted' if scripted else MODEL}")
    client = ScriptedClient() if scripted else Anthropic()
    try:
        for request in REQUESTS:
            print(f"\n=== {request} ===")
            print(f"\nResult: {ask(request, client)}")
    except Exception as exc:  # noqa: BLE001 - any provider error ends the run
        print(f"\nThe model call failed ({MODEL}): {exc}\n"
              "Check ANTHROPIC_API_KEY is set, or run with --scripted.", file=sys.stderr)
        return False
    return True


# --without-agentrust: every tool runs. For comparison.
def run_without_agentrust(scripted: bool) -> int:
    os.environ["AGENTRUST_ENABLED"] = "false"   # the SDK's off switch: wrapped tools pass every call through
    return 0 if ask_agent("off (every tool runs)", scripted) else 1


# --local: a gateway runs inside this program; no account needed.
def run_local(scripted: bool) -> int:
    from agentrust_sdk.embedded import embed_gateway

    # SQLite in a temp dir, gone after the run. embed_gateway sets
    # AGENTRUST_GATEWAY_URL and AGENTRUST_KEY, which governed_tools reads.
    gateway = embed_gateway(db_path=os.path.join(tempfile.mkdtemp(), "gateway.db"))
    gateway.register_manifest(AGENT_ID, MANIFEST)
    try:
        ok = ask_agent(f"local gateway {gateway.url} (no account, nothing kept)", scripted)
    finally:
        gateway.stop()
    return 0 if ok else 1


# --console: the SDK sends every check to your console, which keeps the decisions.
def run_with_console(scripted: bool) -> int:
    missing = [v for v in ("AGENTRUST_GATEWAY_URL", "AGENTRUST_KEY", "AGENTRUST_AGENT_ID") if not os.environ.get(v)]
    if missing:
        print(f"Set {', '.join(missing)} (from the agent you created in the console), or use --local.",
              file=sys.stderr)
        return 2
    console = os.environ["AGENTRUST_GATEWAY_URL"]
    ok = ask_agent(f"console {console}", scripted)
    print(f"\nOpen Events in the console ({console}) and filter by agent ID {AGENT_ID}.")
    return 0 if ok else 1


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description="The Claude calculator agent, governed by AgentTrust.")
    where = parser.add_mutually_exclusive_group()
    where.add_argument("--local", action="store_true", help="run a gateway inside this program, no account (the default)")
    where.add_argument("--console", action="store_true", help="send every check to your AgentTrust console")
    where.add_argument("--without-agentrust", action="store_true", help="for comparison: AgentTrust off, every tool runs")
    parser.add_argument("--scripted", action="store_true", help="replay a fixed conversation: no model, no API key")
    args = parser.parse_args(argv)
    if args.without_agentrust:
        return run_without_agentrust(args.scripted)
    return run_with_console(args.scripted) if args.console else run_local(args.scripted)


if __name__ == "__main__":
    sys.exit(main())

Run it without AgentTrust

First, see what the agent does on its own:

python claude_quickstart.py --without-agentrust --scripted

The scripted agent replays a fixed conversation and does not read tool results, so its last line is the same with AgentTrust on or off. What differs is what each tool returned.

AgentTrust: off (every tool runs) · agent calculator-agent · model scripted

=== Add 3 and 4. ===
  tool add(a=3, b=4) -> 7

Result: 3 + 4 = 7.

=== Divide 10 by 2. ===
  tool divide(a=10, b=2) -> 5.0

Result: That is everything you asked for.

The agent used divide and nothing stopped it.

Where governed_tools sits in the Claude file

This is the only change AgentTrust needs, and it is already in your file, so there is nothing to edit. Find the governed_tools line near the top, right after the tools are defined and before the loop uses them. On your own agent it looks like this:

+ from agentrust_sdk.adapters.claude_agents import governed_tools
- tools = [beta_tool(fn) for fn in [add, multiply, divide]]
+ tools = [beta_tool(fn) for fn in governed_tools([add, multiply, divide], agent_id=AGENT_ID)]

Names, descriptions and argument types stay the same, so Claude sees what it saw before. Every tool call now goes through AgentTrust first. Allowed calls run and their results are checked. Refused calls never run.

Run it locally

python claude_quickstart.py --local --scripted

The program starts a small AgentTrust gateway inside itself and allows only the tools in MANIFEST: add and multiply. divide is not on the list. No account needed.

AgentTrust: local gateway http://127.0.0.1:8765 (no account, nothing kept) · agent calculator-agent · model scripted

=== Add 3 and 4. ===
  tool add(a=3, b=4) -> 7

Result: 3 + 4 = 7.

=== Divide 10 by 2. ===
  tool divide(a=10, b=2) -> Blocked by AgentTrust: Tool-trust hard gate: action 'divide' is
not in this agent's declared_tools (envelope dec75b50-beb1-4603-a8e7-cd8bdad96951)

Result: That is everything you asked for.

add returned 7. divide was refused before its code ran, and Claude was told why, so the run still finished. Nothing is kept after the program exits.

Create the agent in your console

To keep decisions and manage the agent from the console, register it there:

  1. Open Agents, select New agent, name it claude-quickstart, and select Create.
  2. Copy the API Key and the Agent ID before you select Done. The key starts with at_live_ and is shown only once.
  3. Select the agent in the list. Under Manifest & fingerprint, select Declare. Under Declared tools, select + Add tool twice, type add and multiply, and select Save manifest.

Step 3 is the same list MANIFEST holds in the program, now kept in the console. An agent with no declared tools is not restricted, so divide would run.

Run it against the console

export AGENTRUST_GATEWAY_URL=https://console.agent-trust.tech   # your console's address
export AGENTRUST_KEY=at_live_...                                # the API key
export AGENTRUST_AGENT_ID=claude-quickstart                     # the Agent ID

python claude_quickstart.py --console --scripted

The first line names the console, and the run should match the local one: add returns 7 and divide is refused with the same Blocked by AgentTrust text. Drop --scripted to use a real model.

See the decisions in the console

Open Events in the console and type your Agent ID into Filter by Agent ID. You will see one row per decision: add approved before and after it ran, and divide blocked.

AgentTrust Events filtered to langgraph-quickstart, with one Block row and two Approve rows
This capture is from the LangGraph guide, filtered to agent langgraph-quickstart. It is not a capture of the Claude run.

With a real model

We also ran claude-haiku-4-5 as the agent, text only, through the logged-in claude CLI. It is not the tool_runner helper against the live Messages API. add ran. divide was refused and Haiku told the user it was blocked. A $40 refund ran. A $5,000 refund was refused and Haiku explained the $100 limit.

Wrap the function first. Then hand it to beta_tool.
LIMITS

What the Claude check covers and what it doesn't

The same agent, without and with the one change: wrapping its tools.

Your agent can…WithoutWith
Be limited to the tools you allow (you ran this)✗✓
Have a call stopped before it runs (you ran this)✗✓
Tell Claude why a call was refused, and keep running (you ran this)✗✓
Leave a record of every decision, with scores✗✓
Have a tool's result checked before it is returned✗✓
Cap risky amounts, such as a refund over 100✗✓
Hold a risky call until a person approves it✗✓
Be stopped instantly with a kill switch✗✓
Refuse every call if AgentTrust can't be reached (optional)✗✓
Have each reply Claude writes checked (ClaudeAgentGuard)✗✓

✗ means the Anthropic SDK does not provide it, so you would build it yourself. The first three rows are what you just ran. The others are covered by the SDK's tests, run against a real embedded gateway (declared-tool allow-list, amount limits, post-check, ledger rows, hold for a person, fail closed, kill switch, async tools, schema and signature preserved), and the adapter guide (docs/framework-adapters/claude-agents.md). They were not run in this guide.

Limits

  • ClaudeAgentGuard does not cover messages.stream.
  • We did not run tool_runner against the live Messages API. The wrapped tools were checked through beta_tool's schema and .call.
  • The console route was run against a local gateway standing in for the console. The button names come from the console's source, not from a run.

Related checks

MechanismWhat it checks
governed_toolsEach tool call before it runs, and its result after
ClaudeAgentGuardEach reply Claude writes. wrap_client for Anthropic, wrap_async_client for AsyncAnthropic. Fails closed with AGENTRUST_FAILURE_MODE=closed.
NEXT STEPS

Next steps after the Claude tool check

Hold risky calls for a person. Set the agent's mode to Human Review. Rejected or timed-out calls do not run. See the Claude integration docs.

Check every reply too. Wrap your client with ClaudeAgentGuard(agent_id=...).wrap_client(client). A blocked reply raises BlockedError.

Serving tools over MCP? MCP Server Security: Block Unsafe Tool Calls Before They Run does the same with one line. Same check on other frameworks: Block Unsafe AutoGen Tool Calls Before They Run, or build a full governed bot in Guide: Stop Your AI Support Bot from Issuing a Bad Refund.

FREQUENTLY ASKED QUESTIONS

Common questions

beta_tool builds the tool schema from a function's name, docstring and signature. governed_tools keeps all three, so wrap the plain function first and pass the result to beta_tool. Do not apply it to the finished tool object.

A Blocked by AgentTrust: <reason> (envelope ...) result as the tool result, and the run continues. Pass on_block="raise" to raise an error instead.

It is not checked. Wrap every tool the agent has, and give each agent its own agent_id so the audit trail says who acted.

No. The PyPI package agentrust-sdk belongs to another company. Install agentrust-py and import agentrust_sdk.

No. It does not cover messages.stream. It checks replies from messages.create through wrap_client and wrap_async_client.

No. Add --scripted to replay a fixed conversation with no model and no key, and use the local gateway, which needs no account. The console route needs an AgentTrust console account.

READY TO GOVERN YOUR AGENTS?

No AI agent enters production without AgentTrust

Check every tool call before it runs, and keep a record of every decision.

See Pricing & Start Free →

More from the blog

EngineeringOctober 27, 2026EngineeringOctober 27, 2026EngineeringOctober 27, 2026EngineeringOctober 27, 2026EngineeringOctober 28, 2026EngineeringOctober 28, 2026EngineeringOctober 30, 2026AI ComplianceJuly 22, 2026AI ComplianceJuly 22, 2026AI GovernanceJuly 22, 2026AI GovernanceJuly 22, 2026AI ArchitectureJuly 22, 2026AI SecurityJuly 16, 2026MLOpsJuly 10, 2026AI ImplementationJuly 8, 2026AI Agent ArchitectureJuly 5, 2026AI Agent ArchitectureJune 30, 2026EngineeringJune 23, 2026AI Agent ArchitectureJuly 2, 2026AI Agent ArchitectureJuly 1, 2026IntegrationsJuly 1, 2026IntegrationsJuly 1, 2026IntegrationsJuly 2, 2026AI SecurityJuly 3, 2026AI SecurityJuly 1, 2026AI ComplianceJuly 2, 2026AI ComplianceJuly 3, 2026AI StrategyJuly 2, 2026AI StrategyJuly 3, 2026AI StrategyJuly 3, 2026AI StrategyJuly 3, 2026AI GovernanceJuly 28, 2026Healthcare AIJuly 28, 2026ArchitectureJuly 29, 2026ArchitectureJuly 29, 2026AI StrategyJuly 29, 2026AI StrategyJuly 30, 2026AI SecurityJuly 30, 2026AI ComplianceJuly 30, 2026EngineeringJuly 30, 2026AI GovernanceAugust 4, 2026EngineeringAugust 4, 2026EngineeringAugust 4, 2026Agent SecurityAugust 19, 2026AI ComplianceSeptember 4, 2026EngineeringSeptember 4, 2026AI StrategySeptember 4, 2026AI GovernanceSeptember 4, 2026