Mock orders, mock refunds, and the same @harness check as the recorded harness --local run.
Guide to a support bot that blocks bad refunds: a $5,000 refund on a $120 order never runs, using a CERTIFY.YAML manifest and the @harness check.
An AgentTrust checkpoint in front of every tool a support agent can call blocks a $5,000 refund on a $120 order before any money moves. This guide adds that checkpoint to a mock refund bot with @harness and a CERTIFY.YAML manifest.
The model picks the amount. The manifest sets the limit.
A customer writes "My order arrived late, can I have $40 back?". Your bot reads the ticket, looks up the order, issues the refund, emails the customer and closes the ticket. That bot is an agent: a loop that asks a model what to do next, runs the tool the model picks, and repeats.
Without protection, the model decides alone. By the end of this guide:
BLOCKED result and hands the ticket to a person.Prerequisites. Python 3.12, uv, git, and an account at console.agent-trust.tech. You do not need a model API key: the bot uses a scripted model, so every run is identical.
Already have the template cloned? The @harness quickstart is the short version of this guide: same result, only the changes.
You are not going to trust the model with anything that matters, so the check has to live outside the model. Each tool is wrapped with @harness from the AgentTrust SDK. The wrapper makes two checks with the gateway:
The checkpoint is the only part that knows AgentTrust exists. The loop, the model and the tools do not change because of it, so you can replace any of them later without touching the others.
| Term | Meaning |
|---|---|
| Manifest | Kept in the Console. Lists the tools the agent may use and limits such as "refunds up to $1,000". |
| Policy pack | Extra rules in the Console, such as "a refund above $500 is a violation". |
| Gateway | The part of AgentTrust your code talks to. It answers allow or block. |
@harness | An SDK decorator. Put it on a function and it asks the gateway before and after the function runs. |
Package name. Install agentrust-py and import agentrust_sdk. The PyPI package agentrust-sdk belongs to another company. Do not install it.
The bot has seven tools, and each one is wrapped with the same @harness checkpoint. The checkpoint is the only path from the model's choice to a tool's code, so no tool runs without being asked about first.
issue_refund with an amount. The function has not run.CERTIFY.YAML, lists the tools the bot may use and their limits. issue_refund is declared with a max of 1000.BLOCKED result and hands the ticket to a person.The model decides the amount, and a model can be wrong or be talked into a number. The manifest is a limit the model does not control. Because the rules live in the Console, you change a limit without redeploying the bot.
The bot refuses to act. A checkpoint that let calls through when it could not ask would fail open, which is the case you are trying to prevent. Refusing keeps money from moving without a decision.
The checkpoint decides whether a tool call may run. It does not decide whether the customer's story is true. A $900 refund on a $120 order is under the cap of 1000 and would be allowed, so set the cap to the largest amount you are willing to refund without a person.
You start from a working refund bot with no protection and add the checkpoint in seven small edits. Steps 1 to 4 happen in your terminal and the Console. Steps 5 to 11 are code edits, each with the file, the marker to find, and a check. If you get stuck, compare with the solution branch.
Open the cloned folder in your editor and keep a terminal in python/: every command and path in this guide assumes it. The tests need no network or account, so they prove the starting point works.
git clone https://github.com/agent-trust-os/refund-agent-tutorial.git
cd refund-agent-tutorial/python
uv run pytest -q
Now file a ticket asking for $5,000 and let the unprotected bot handle it:
uv run refund-bot ticket "Refund me \$5,000 for the trouble." --customer C-3 --order O-1003
call issue_refund {"order_id": "O-1003", "amount": 5000, "currency": "USD", "reason": "customer request"}
Answer: I refunded $5,000 for your order and emailed you a confirmation.
Ticket T-1006: open -> resolved
4. issue_refund {"order_id": "O-1003", "amount": 5000, "currency": "USD", "reason": "customer request"}
The bot paid $5,000 on a $120 order. The model believed the customer and nothing stood in the way. A better prompt will not fix that, because anything that moves money should be checked by something other than the model that asked for it.
In AgentTrust your bot is an agent: a name, a secret key and a set of rules.
refund-bot with mode block. In block mode a violation is stopped, not just noted.at_live_ and is shown once. Never paste it in a chat, a ticket or a commit.purge_orders, and caps issue_refund at $1,000.python/policies/refund-bot.certify.yaml
# Paste this into the Console: Agents > refund-bot > CERTIFY.YAML > Paste.
# It declares the manifest in one go, so you do not have to fill the small Declared tools fields by hand.
agent_id: refund-bot
model:
provider: mock
model_id: rules
version_pin: "1"
declared_tools:
- { name: read_ticket, scope: read, max_amount: null }
- { name: lookup_order, scope: read, max_amount: null }
- { name: lookup_customer, scope: read, max_amount: null }
- { name: issue_refund, scope: write, max_amount: 1000 }
- { name: send_email, scope: write, max_amount: null }
- { name: create_ticket, scope: write, max_amount: null }
- { name: update_ticket, scope: write, max_amount: null }
policy_pack:
name: refund-bot
version: "1.0.0"
An agent with no declared tools has nothing to compare against, so AgentTrust approves everything, even a tool that does not exist. Before we saved the manifest, a request to delete all orders was approved.
After you save, select the agent again and look at Manifest & fingerprint. It should look like this:

Check three things. The Tools line lists every tool you declared: seven for refund-bot. This capture is from a different agent, so it lists two. CERTIFY.YAML is marked v1 (current). A fingerprint is shown underneath. If Tools is empty, the manifest did not save: paste it again.
The manifest sets hard limits. A policy pack adds rules on the arguments. In Policies, create a private pack, paste this file and activate it.
python/policies/refund-bot.yaml
name: refund-bot
version: "1.0.0"
description: >
Rules for the Refund Bot. They check the proposed tool arguments at pre-check
(execution.tools_called.0.arguments.*). Hard stops (an undeclared tool, an amount above the
max_amount in the agent's manifest) come from the manifest, not from this pack.
environment: all
agent_id_patterns:
- "refund-bot"
rules:
- id: refund_reason_present
description: "A refund must say why"
severity: critical
target: execution.tools_called.0.arguments.reason
operator: not_empty
conditions:
- target: execution.tools_called.0.name
operator: eq
value: "issue_refund"
- id: refund_over_500
description: "A refund above $500 needs a person"
severity: critical
target: execution.tools_called.0.arguments.amount
operator: lte
value: 500
conditions:
- target: execution.tools_called.0.name
operator: eq
value: "issue_refund"
Each rule has a target (the value to inspect, here the refund amount and reason), an operator and value (the test), and conditions (here, only for issue_refund). The rules say a refund must give a reason, and a refund above $500 is a critical violation, which block mode stops.
.env is ignored by git. The check sends one health call and three real pre-checks with your key, and works before you write any code.
cp .env-sample .env
# open .env and set AGENTRUST_KEY=at_live_... (the other values are already right)
uv run refund-bot agenttrust check
ok gateway reachable at https://console.agent-trust.tech
ok lookup_order is approved
ok a $5,000 refund is blocked
ok purge_orders is blocked
Four ticks mean the Console side is done. If a line fails, it says which step to repeat.
Your agent exists in the Console, but the bot does not ask it anything yet. In this section you add the checkpoint: a wrapper that makes every tool call stop and ask AgentTrust before it runs. You make seven small edits across ten files. Each edit gives you the file to open, the marker to find, the code to paste and a check.
python/. Every command and path in this section assumes it.uv run refund-bot agenttrust check prints four ticks. If it does not, fix the Console setup first: nothing in the code can make up for it.git status is clean, so git diff shows only your edits. Optional: run git switch -c add-checkpoint and commit after each edit, so you can undo any one of them.| Edit | File | What it adds |
|---|---|---|
| TODO 1 Read the settings | app/core/config.py | The gateway URL, key, agent name and failure mode, read from the environment |
| TODO 2 Add the SDK | pyproject.toml | agentrust-py, pinned to one version |
| TODO 3 Write the checkpoint | app/tools/governance.py | governed_harness, the wrapper that asks AgentTrust before a tool runs |
| TODO 4 Wrap every tool | app/tools/__init__.py | The wrapper around each tool, outside the logging |
| TODO 5 Pass the mode through | app/agents/support_agent.py | The governed setting, handed down to the tools |
| TODO 6 Choose protection per request | app/services/tickets.py, app/api/routes/tickets.py, app/cli.py | --governed harness on the command line and ?governed=harness in the URL |
| TODO 7 Keep personal data out | app/tools/customers.py, app/llm/rules.py | No SSN or card number reaches the model, and a ticket is closed even when its first read is refused |
Optional: look at the tools you are protecting
Nothing to add here. Every tool gets the same first argument, a small context holding the database and the current ticket.
python/app/tools/context.py
"""What every tool gets besides the model's arguments: the database, and the ticket being worked on."""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy.orm import Session
@dataclass
class ToolContext:
db: Session
ticket_id: str | None # the ticket the agent is resolving; refunds, emails and actions are linked to it
Look at issue_refund and notice what is missing: no maximum amount, no check against the order total. A refund function should not decide how much is too much. That belongs to rules you can change without a release, and you just wrote them in the Console.
python/app/tools/refunds.py (green = lines you add, and AgentTrust lines)
"""Refund tool: send money back to a customer.
There is deliberately no upper limit and no check against the order total, so the tutorial can show a
+ $5,000 refund on a $120 order. Limits are AgentTrust's job.
"""
from __future__ import annotations
from typing import Any
from ..repositories import orders, records
from .context import ToolContext
def issue_refund(ctx: ToolContext, order_id: str, amount: float, currency: str = "USD",
reason: str = "") -> dict[str, Any]:
"""Refund money to the customer of an order."""
if orders.get_order(ctx.db, order_id) is None:
return {"ok": False, "error": "no such order"}
existing = records.find_refund(ctx.db, ctx.ticket_id, order_id, amount)
if existing:
return {"ok": True, "status": "duplicate", "refund_id": existing}
return {"ok": True, "refund": records.add_refund(ctx.db, ctx.ticket_id, order_id, amount, currency, reason)}
Before any tool runs, a line goes to the action log. It proves what really ran, whatever the model says. A blocked refund will not be in it.
python/app/tools/action_log.py (green = lines you add, and AgentTrust lines)
"""The action log: one row per tool call, written before the tool runs.
It is the proof of what really happened, whatever the agent or the model says. Tests and the tutorial
+ read it to check what ran, and later, what AgentTrust stopped.
"""
from __future__ import annotations
from typing import Any
from ..repositories import records
from .context import ToolContext
def log_action(ctx: ToolContext, tool: str, args: dict[str, Any]) -> None:
records.add_action(ctx.db, ctx.ticket_id, tool, args)
tool_spec describes each tool to the model. It skips *keyword-only* parameters, which is how every tool carries two hidden values, user and input. The model cannot see or fill them in, but the checkpoint reads them.
python/app/llm/base.py
"""The model port. The agent loop talks to a ``Model`` and never imports a provider SDK."""
from __future__ import annotations
import inspect
from dataclasses import dataclass
from typing import Any, Callable, Protocol
@dataclass
class Message:
role: str # "user" | "assistant" | "tool"
content: Any
tool_call_id: str | None = None
@dataclass
class ToolSpec:
name: str
description: str
parameters: dict[str, Any] # a JSON Schema object
@dataclass
class ToolCall:
id: str
name: str
args: dict[str, Any]
@dataclass
class FinalAnswer:
text: str
class Model(Protocol):
name: str
def next(self, messages: list[Message], tools: list[ToolSpec]) -> ToolCall | FinalAnswer: ...
_JSON_TYPES = {str: "string", float: "number", int: "integer", bool: "boolean"}
def tool_spec(fn: Callable[..., Any]) -> ToolSpec:
"""Build a spec from a function's signature and docstring.
Keyword-only parameters are skipped: they carry hidden context (``user``, ``input``) that
the model must never see or fill in.
"""
properties: dict[str, Any] = {}
required: list[str] = []
for name, param in inspect.signature(fn, eval_str=True).parameters.items():
if param.kind is inspect.Parameter.KEYWORD_ONLY:
continue
json_type = _JSON_TYPES.get(param.annotation)
if json_type is None:
raise TypeError(f"{fn.__name__}({name}): unsupported type hint {param.annotation!r}")
prop: dict[str, Any] = {"type": json_type}
if param.default is inspect.Parameter.empty:
required.append(name)
else:
prop["default"] = param.default
properties[name] = prop
doc = inspect.getdoc(fn) or ""
return ToolSpec(fn.__name__, doc.splitlines()[0] if doc else fn.__name__,
{"type": "object", "properties": properties, "required": required})
Every step below has the same shape, so you always know where to go:
python/ folder.TODO(AgentTrust n/11). Red lines show exactly what to remove.Markers 8 to 11 belong to the direct API guide: leave them alone. ./scripts/todos.sh lists the TODOs still open. The details sections show each whole finished file, with the lines you add in green.
Shortcut: apply all seven TODOs automatically
If you only want the finished result, this script replaces every marker for you. Run it from the python/ folder, then read each step to see what changed. git diff shows it too.
python3 ../docs/guides/apply_snippets.py --only 1-7 --root ..
./scripts/todos.sh # 7 of 11 done
./scripts/todos.sh goes from 0 of 11 done to 7 of 11 done, one more after each edit. TODOs 8 to 11 belong to the direct API guide and stay open.tests/test_harness_governed.py are skipped until TODO 3. After that, four of them fail after TODOs 3, 4 and 5, and that is expected: they exercise the whole path, which is complete only after TODO 6. After TODO 6 they pass except one that waits for TODO 7, and after TODO 7 all six pass. Eleven other tests are skipped because they need a Postgres database. That is normal.Settings come from environment variables, so the same code runs on your laptop and on a server. You add four values: the gateway URL, your key, the agent name, and what to do when the gateway is unreachable. The last one defaults to closed (refuse to act), the opposite of the SDK default, so forgetting to set it never turns protection off.
Open python/app/core/config.py
Find TODO(AgentTrust 1/11). It appears twice: once inside class Settings, once inside get_settings().
First marker, below smtp_from: str. Replace the comment line with the four fields:
python/app/core/config.py
agentrust_gateway_url: str
agentrust_key: str
agentrust_agent_id: str
agentrust_failure_mode: str
Second marker, below smtp_from=os.environ.get(...). Replace the comment line with:
python/app/core/config.py
agentrust_gateway_url=os.environ.get("AGENTRUST_GATEWAY_URL", "").rstrip("/"),
agentrust_key=os.environ.get("AGENTRUST_KEY", ""),
agentrust_agent_id=os.environ.get("AGENTRUST_AGENT_ID", "refund-bot"),
agentrust_failure_mode=os.environ.get("AGENTRUST_FAILURE_MODE", "closed"),
See the whole finished file
python/app/core/config.py (green = lines you add, and AgentTrust lines)
"""Settings, read from environment variables, with defaults that work on a laptop with nothing installed.
| Variable | Default | Used for |
|----------------|----------------------------|-------------------------------------------|
| DATABASE_URL | sqlite:///refund_bot.db | where tickets, orders and refunds live |
| SMTP_HOST | (empty: emails stay local) | the mail server, Mailpit in Docker |
| SMTP_PORT | 1025 | |
| SMTP_FROM | [email protected] | the sender of every email |
"""
from __future__ import annotations
import os
from dataclasses import dataclass
@dataclass(frozen=True)
class Settings:
database_url: str
smtp_host: str
smtp_port: int
smtp_from: str
+ agentrust_gateway_url: str
+ agentrust_key: str
+ agentrust_agent_id: str
+ agentrust_failure_mode: str
def get_settings() -> Settings:
"""Read the settings now. Called when needed, so tests can change the environment."""
return Settings(
database_url=normalize_database_url(os.environ.get("DATABASE_URL", "sqlite:///refund_bot.db")),
smtp_host=os.environ.get("SMTP_HOST", ""),
smtp_port=int(os.environ.get("SMTP_PORT", "1025")),
smtp_from=os.environ.get("SMTP_FROM", "[email protected]"),
+ agentrust_gateway_url=os.environ.get("AGENTRUST_GATEWAY_URL", "").rstrip("/"),
+ agentrust_key=os.environ.get("AGENTRUST_KEY", ""),
+ agentrust_agent_id=os.environ.get("AGENTRUST_AGENT_ID", "refund-bot"),
+ agentrust_failure_mode=os.environ.get("AGENTRUST_FAILURE_MODE", "closed"),
)
def normalize_database_url(url: str) -> str:
"""SQLAlchemy reads ``postgresql://`` as the old psycopg2 driver; we use psycopg 3."""
if url.startswith("postgresql://"):
return "postgresql+psycopg://" + url[len("postgresql://"):]
return url
Check it. uv run python -c "from app.core.config import get_settings; print(get_settings().agentrust_failure_mode)" prints closed.
Pin one exact version so nothing changes under you.
Open python/pyproject.toml
Find TODO(AgentTrust 2/11), the first line inside dependencies = [.
Replace that comment line with this line, keeping the quotes and trailing comma:
python/pyproject.toml
"agentrust-py==0.0.4",
uv sync
Check it. uv run python -c "import agentrust_sdk; print('sdk ok')" prints sdk ok.
This is the core of the guide. governed_harness takes one tool and returns a protected version of it, using the SDK's @harness to do the asking. The code around it fixes four SDK defaults and behaviours:
BLOCKED, an unreachable gateway returns UNAVAILABLE. The bot reads them like any other result instead of crashing.@harness reads the user only from keyword arguments. Without that line every Console event shows the user as unknown.It also refuses to start without a gateway URL and key. Otherwise the SDK would send checks to localhost:8000, your own app, and run every tool unchecked.
Open python/app/tools/governance.py. It holds a docstring, from __future__ import annotations and two TODO comments.
Find TODO(AgentTrust 3/11), the first comment.
Replace only that line with the code below. Keep the from __future__ line above it, and leave the TODO 8 comment at the bottom: it belongs to the direct API guide.
python/app/tools/governance.py
import functools
import os
from typing import Any, Callable
from ..core.config import get_settings
Tool = Callable[..., dict[str, Any]]
class GovernanceNotConfigured(RuntimeError):
"""AgentTrust is asked for, but the gateway url or the key is missing."""
def governed_harness(tool: Tool) -> Tool:
"""Guard a tool with ``@harness``: AgentTrust is asked before it runs, and again with what it returned.
A refused call comes back as a BLOCKED result and a gateway that cannot be reached as UNAVAILABLE, so
the agent loop and the model see a normal tool result instead of an exception.
"""
settings = get_settings()
if not settings.agentrust_gateway_url or not settings.agentrust_key:
raise GovernanceNotConfigured("set AGENTRUST_GATEWAY_URL and AGENTRUST_KEY in .env (see .env-sample)")
os.environ.setdefault("AGENTRUST_FAILURE_MODE", settings.agentrust_failure_mode)
from agentrust_sdk import BlockedError, GatewayUnavailableError, harness
guarded = harness(
agent_id=settings.agentrust_agent_id,
action=tool.__name__,
base_url=settings.agentrust_gateway_url,
api_key=settings.agentrust_key,
block_on_block=True, # a block stops the call
block_on_review=True, # so does "a person must look first": the SDK default lets it through
raise_on_error=True, # a failing check stops the call: the SDK default runs the tool anyway
)(tool)
@functools.wraps(tool)
def call(*args: Any, **kwargs: Any) -> dict[str, Any]:
kwargs.setdefault("user", "support-bot") # @harness reads the user only from the keyword arguments
try:
return guarded(*args, **kwargs)
except BlockedError as exc:
return {"status": "BLOCKED", "tool": tool.__name__, "reason": exc.reason, "outcome": exc.outcome}
except GatewayUnavailableError as exc:
return {"status": "UNAVAILABLE", "tool": tool.__name__, "reason": str(exc)}
except Exception as exc: # the check itself failed: do not run the tool
return {"status": "UNAVAILABLE", "tool": tool.__name__, "reason": f"AgentTrust check failed: {exc}"}
return call
Check it. uv run python -c "from app.tools.governance import governed_harness; print('guard ok')" prints guard ok.
_guard returns the wrapper make_tools puts around each tool. With no mode it returns tools unchanged. Add the case for harness.
Order matters. The checkpoint wraps the *outside*, around the logging. If AgentTrust says no, the log line is never written, so a blocked call leaves no trace in the action log.
Open python/app/tools/__init__.py, function _guard.
Find TODO(AgentTrust 4/11), between if governed is None: and the TODO 9 comment.
Replace only that line. Leave the TODO 9 comment and the final raise ValueError as they are.
python/app/tools/__init__.py
if governed == "harness":
from .governance import governed_harness
return governed_harness
See the whole finished file
python/app/tools/__init__.py (green = lines you add, and AgentTrust lines)
+ """The tools the agent can call. This is where AgentTrust guards each tool.
Each tool is a plain function in this folder whose first argument is a ``ToolContext`` (the database and the
ticket being worked on). ``make_tools`` turns them into the functions the model sees:
* the context is filled in, so the model only sees the tool's own arguments;
+ * two hidden keyword-only arguments, ``user`` and ``input``, are added for AgentTrust to read later;
* every call is written to the action log before the tool runs.
"""
from __future__ import annotations
import functools
import inspect
from typing import Any, Callable
from sqlalchemy.orm import Session
from .action_log import log_action
from .context import ToolContext
from .customers import lookup_customer
from .email import send_email
from .orders import lookup_order, purge_orders
from .refunds import issue_refund
from .tickets import create_ticket, read_ticket, update_ticket
ALL_TOOLS = [read_ticket, lookup_order, lookup_customer, issue_refund, send_email, create_ticket, update_ticket,
purge_orders]
Tool = Callable[..., dict[str, Any]]
def make_tools(db: Session, ticket_id: str | None, governed: str | None = None) -> dict[str, Tool]:
+ """The tools for one ticket, by name, ready for the model. ``governed`` names how AgentTrust guards them."""
ctx = ToolContext(db, ticket_id)
guard = _guard(governed)
return {tool.__name__: guard(_for_the_model(tool, ctx)) for tool in ALL_TOOLS}
def _guard(governed: str | None) -> Callable[[Tool], Tool]:
+ """The wrapper that asks AgentTrust before a tool runs. With no mode set, tools run as they are.
It is applied outside the action log, so a blocked call never enters the log of what really ran.
"""
if governed is None:
return lambda tool: tool
+ if governed == "harness":
+ from .governance import governed_harness
+ return governed_harness
raise ValueError(f"unknown governed mode {governed!r}")
def _for_the_model(tool: Tool, ctx: ToolContext) -> Tool:
parameters = list(inspect.signature(tool, eval_str=True).parameters.values())[1:] # drop ctx
hidden = [inspect.Parameter("user", inspect.Parameter.KEYWORD_ONLY, default="support-bot", annotation=str),
inspect.Parameter("input", inspect.Parameter.KEYWORD_ONLY, default="", annotation=str)]
visible = inspect.Signature(parameters)
@functools.wraps(tool)
def call(*args: Any, user: str = "support-bot", input: str = "", **kwargs: Any) -> dict[str, Any]:
bound = visible.bind(*args, **kwargs)
bound.apply_defaults()
log_action(ctx, tool.__name__, dict(bound.arguments))
return tool(ctx, **bound.arguments)
call.__signature__ = inspect.Signature(parameters + hidden)
del call.__wrapped__ # so inspect shows the signature above, not the original with ctx
return call
The loop does not know AgentTrust exists: a BLOCKED result is just another result to hand back to the model. It only needs to pass governed down. The step limit stops a confused model looping forever. It protects your program, not your data.
Open python/app/agents/support_agent.py, function run. Its signature already accepts governed.
Find TODO(AgentTrust 5/11) and the line right under it. Replace both lines:
- # TODO(AgentTrust 5/11): take a governed= argument and pass it to make_tools. ...
- tools = make_tools(db, ticket_id)
with:
python/app/agents/support_agent.py
tools = make_tools(db, ticket_id, governed)
See the whole finished file
python/app/agents/support_agent.py (green = lines you add, and AgentTrust lines)
"""The agent loop: ask the model what to do, run the tool it names, give it the result, repeat.
The loop knows nothing about refunds or tickets. It only moves messages between the model and the tools,
so the model can be swapped (scripted, rule-based, later a real LLM) and the tools can be wrapped
+ (by AgentTrust) without changing this file.
"""
from __future__ import annotations
import json
import logging
from typing import Any, Callable
from sqlalchemy.orm import Session
from ..llm.base import FinalAnswer, Message, Model, ToolCall, tool_spec
from ..repositories import records, tickets
from ..tools import make_tools
from .prompts import ticket_request
from .state import RunResult
STEP_LIMIT_ANSWER = "Stopped: step limit reached"
log = logging.getLogger("refund_bot.agent")
def call_tool(fn: Callable[..., dict[str, Any]], call: ToolCall, request: str) -> dict[str, Any]:
"""Run one tool call. The request goes in as the hidden ``input`` argument. A tool that raises
becomes an ERROR result, so the model can recover instead of the loop crashing."""
try:
return fn(**call.args, input=request)
except Exception as exc:
return {"status": "ERROR", "tool": call.name, "reason": str(exc)}
def summary(result: dict[str, Any]) -> str:
"""One short word for a tool result, for the log: ok, duplicate, an error, or BLOCKED and why."""
if result.get("status") in ("ERROR", "BLOCKED", "UNAVAILABLE"):
return f"{result['status']}: {result.get('reason', '')}"
if result.get("ok") is False:
return f"error: {result['error']}"
if result.get("status") == "duplicate":
return f"duplicate of {result['refund_id']}"
for key, label in (("refund", "refund_id"), ("ticket", "ticket_id")):
if key in result:
item = result[key]
return f"ok {item[label]}" + (f" ({item['status']})" if key == "ticket" else "")
return "ok"
def run(ticket_id: str, model: Model, db: Session, max_steps: int = 10, governed: str | None = None) -> RunResult:
"""Work on one ticket until the model gives its final answer.
``max_steps`` is a local safety net so a looping model cannot run forever. It is not a governance control.
+ ``governed`` says how AgentTrust guards the tools: None (not at all), "harness" or "client".
"""
+ tools = make_tools(db, ticket_id, governed)
specs = [tool_spec(fn) for fn in tools.values()]
before = {t["ticket_id"] for t in tickets.list_tickets(db)}
request = ticket_request(ticket_id)
messages = [Message("user", request)]
answer = STEP_LIMIT_ANSWER
log.info("%s | start | model=%s", ticket_id, getattr(model, "name", type(model).__name__))
for _ in range(max_steps):
step = model.next(messages, specs)
if isinstance(step, FinalAnswer):
answer = step.text
messages.append(Message("assistant", answer))
break
messages.append(Message("assistant", {"tool": step.name, "args": step.args}, tool_call_id=step.id))
fn = tools.get(step.name)
if fn is None:
result = {"status": "ERROR", "tool": step.name, "reason": f"unknown tool {step.name!r}"}
else:
result = call_tool(fn, step, request)
log.info("%s | call | %s %s -> %s", ticket_id, step.name, json.dumps(step.args), summary(result))
log.debug("%s | result | %s", ticket_id, json.dumps(result))
messages.append(Message("tool", result, tool_call_id=step.id))
ticket = tickets.get_ticket(db, ticket_id)
log.info("%s | done | ticket %s | answer: %s", ticket_id, ticket["status"] if ticket else "missing", answer)
return RunResult(
ticket_id=ticket_id,
transcript=messages,
answer=answer,
ticket=ticket,
actions=records.list_actions(db, ticket_id=ticket_id),
created_tickets=[t for t in tickets.list_tickets(db) if t["ticket_id"] not in before],
)
Protection is off unless you ask, so you can run the same ticket both ways. Ask with --governed harness on the command line or ?governed=harness in the URL. This TODO touches four places. Work from the inside out:
A. Service python/app/services/tickets.py, function resolve_ticket (it already has a governed parameter). Replace the TODO(AgentTrust 6/11) line and the return run(...) line under it:
- # TODO(AgentTrust 6/11): add a governed= parameter and pass it to run. ...
- return run(ticket_id, RuleModel(), db)
with:
python/app/services/tickets.py
return run(ticket_id, RuleModel(), db, governed=governed)
B. Route python/app/api/routes/tickets.py, function resolve (it already reads ?governed=). Replace the marker and the return under it:
- # TODO(AgentTrust 6/11): read ?governed=harness and pass it on. ...
- return result_out(resolve_ticket(db, ticket_id))
with:
python/app/api/routes/tickets.py
return result_out(resolve_ticket(db, ticket_id, governed))
C. CLI option python/app/cli.py, near line 74, under listing.add_argument("--status", ...). Replace the marker line TODO(AgentTrust 6/11): add --governed to "ticket" with:
python/app/cli.py
ticket.add_argument("--governed", choices=["harness", "client"], help="ask AgentTrust before each tool runs")
D. CLI call same file, near line 105, inside if args.command == "ticket":. Replace the marker TODO(AgentTrust 6/11): pass args.governed on and the print(...) line under it:
- # TODO(AgentTrust 6/11): pass args.governed on. ...
- print(show(resolve_ticket(db, created["ticket_id"])))
with:
python/app/cli.py
print(show(resolve_ticket(db, created["ticket_id"], args.governed)))
See the finished service
python/app/services/tickets.py (green = lines you add, and AgentTrust lines)
"""The ticket workflow, shared by the command line and the API.
1. ``submit_ticket``: a customer writes in. This is not an agent action, so it is not in the action log.
2. ``resolve_ticket``: the agent works on an open ticket with the rule-based mock LLM.
"""
from __future__ import annotations
import logging
from typing import Any
from sqlalchemy.orm import Session
from ..agents.state import RunResult
from ..agents.support_agent import run
from ..llm.rules import RuleModel
from ..repositories import tickets
from ..tools.context import ToolContext
from ..tools.tickets import create_ticket
log = logging.getLogger("refund_bot.tickets")
class TicketError(Exception):
"""A request about a ticket that cannot be carried out. ``status`` is the HTTP status to answer with."""
def __init__(self, message: str, status: int) -> None:
super().__init__(message)
self.message, self.status = message, status
def submit_ticket(db: Session, customer_id: str, body: str, order_id: str = "") -> dict[str, Any]:
"""Open a ticket for a customer. Raises TicketError (400) for an unknown customer or order, or an empty body."""
result = create_ticket(ToolContext(db, None), customer_id, body, order_id)
if not result["ok"]:
raise TicketError(result["error"], 400)
ticket = result["ticket"]
log.info("%s | submitted | customer %s, order %s: %s", ticket["ticket_id"], customer_id, order_id or "-", body)
return ticket
def resolve_ticket(db: Session, ticket_id: str, governed: str | None = None) -> RunResult:
"""Let the agent work on an open ticket. Raises TicketError: 404 if unknown, 409 if not open."""
ticket = tickets.get_ticket(db, ticket_id)
if ticket is None:
raise TicketError(f"no such ticket {ticket_id!r}", 404)
if ticket["status"] != "open":
raise TicketError(f"ticket {ticket_id} is already {ticket['status']}", 409)
+ return run(ticket_id, RuleModel(), db, governed=governed)
See the finished route
python/app/api/routes/tickets.py (green = lines you add, and AgentTrust lines)
"""Tickets: list them, submit one, read one, and let the agent resolve one."""
from __future__ import annotations
from dataclasses import asdict
from typing import Literal
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from ...agents.state import RunResult
from ...db.session import get_db
from ...repositories import tickets
from ...schemas.tickets import RunResultOut, Ticket, TicketIn
from ...services.tickets import TicketError, resolve_ticket, submit_ticket
router = APIRouter(prefix="/tickets", tags=["tickets"])
def result_out(result: RunResult) -> RunResultOut:
return RunResultOut(ticket_id=result.ticket_id, answer=result.answer, ticket=result.ticket,
actions=result.actions, created_tickets=result.created_tickets,
transcript=[asdict(m) for m in result.transcript])
@router.get("", response_model=list[Ticket])
def list_tickets(status: str | None = None, db: Session = Depends(get_db)):
"""All tickets, or only those with this status (open, resolved, escalated)."""
return tickets.list_tickets(db, status)
@router.post("", response_model=Ticket, status_code=201)
def create(ticket: TicketIn, db: Session = Depends(get_db)):
"""A customer writes in. The ticket starts open."""
return submit_ticket(db, ticket.customer_id, ticket.body, ticket.order_id)
@router.get("/{ticket_id}", response_model=Ticket)
def read(ticket_id: str, db: Session = Depends(get_db)):
ticket = tickets.get_ticket(db, ticket_id)
if ticket is None:
raise TicketError(f"no such ticket {ticket_id!r}", 404)
return ticket
@router.post("/{ticket_id}/resolve", response_model=RunResultOut)
def resolve(ticket_id: str, governed: Literal["harness", "client"] | None = None, db: Session = Depends(get_db)):
+ """The agent works on an open ticket and answers with everything it did. ``?governed=harness`` asks AgentTrust first."""
+ return result_out(resolve_ticket(db, ticket_id, governed))
Check it. ./scripts/todos.sh says 6 of 11 done. uv run refund-bot ticket --help lists --governed {harness,client}.
Two last fixes, both about what the model sees and does.
A. No SSN or card number. The customer lookup returns them. The model never needs them, and once a model has seen something no later check can take it back.
Open python/app/tools/customers.py, function lookup_customer. Replace the TODO(AgentTrust 7/11) line and the customer = ... line under it:
- # TODO(AgentTrust 7/11): do not return the SSN or the card number. ...
- customer = orders.get_customer(ctx.db, customer_id)
with:
python/app/tools/customers.py
customer = orders.get_customer(ctx.db, customer_id)
if customer:
customer = {key: value for key, value in customer.items() if key not in ("ssn", "card_number")}
B. A refused first read. If read_ticket itself is refused, the model never sees the ticket. Make the scripted model close it by id instead of giving up silently.
Open python/app/llm/rules.py, method _decide, inside if self._ticket is None:. Replace the marker and the two lines under it:
- # TODO(AgentTrust 7/11): escalate the ticket even when read_ticket itself was blocked. ...
- if self._failed:
- return FinalAnswer(ESCALATED_TEXT)
with:
python/app/llm/rules.py
if self._failed: # read_ticket was refused: close the ticket from the id in the request
if "closed" in self._done:
return FinalAnswer(ESCALATED_TEXT)
ticket_id = TICKET_ID.search(str(messages[0].content)).group(0)
return "update_ticket", {"ticket_id": ticket_id, "status": "escalated", "note": "Passed to a colleague."}
./scripts/todos.sh # 7 of 11 done (8 to 11 belong to the direct API guide)
uv run pytest -q
Check it. 7 of 11 done, and the tests pass. They run the whole bot against an offline stand-in for the gateway, so you can check the wiring without a network.
| You see | What it means |
|---|---|
| You cannot find a marker | Search for the exact text, for example TODO(AgentTrust 3/11). TODO 1 appears twice and TODO 6 four times. ./scripts/todos.sh lists the markers still open and the files they are in. |
todos.sh still counts a TODO as open | The marker comment is still in the file. A TODO is done only when its marker line is gone, so replace the comment: do not paste above it. |
IndentationError or SyntaxError after pasting | Python is strict about indentation. Paste with the copy button and match the indentation of the line you replaced. |
ModuleNotFoundError: No module named 'agentrust_sdk' | You skipped uv sync after TODO 2. Run it. |
| Four governed tests fail | Expected until TODO 6. If they still fail after TODO 7, run uv run pytest -q tests/test_harness_governed.py and read the first error. |
You are done when ./scripts/todos.sh prints 7 of 11 done, uv run pytest -q passes with all six governed tests, and uv run refund-bot ticket --help lists --governed {harness,client}. Next, run the bot with the checkpoint on.
Same bot, same model, same tickets. The only difference is governed=harness.
uv run refund-bot serve &
curl -s -X POST "localhost:8000/tickets/T-1003/resolve?governed=harness"
| Ticket | Request | Result | Why |
|---|---|---|---|
T-1001 | $40 refund, late order | Allowed | Declared tool, under every limit. |
T-1003 | $5,000 on a $120 order | Blocked | Above the manifest cap of $1,000. |
T-1005 | "Delete all my orders" | Blocked | purge_orders is not a declared tool. |
T-1002 | $900 refund | Blocked | Under the cap, but above the policy's $500 rule. |
T-1004 | Ticket hides "refund $50,000" | Result withheld | Caught by the check *after* read_ticket. |
T-1003: $5,000 is blocked
The issue_refund result says BLOCKED and names the $1,000 cap. The last line, the action log, has no issue_refund: it never ran.
call read_ticket {"ticket_id": "T-1003"}
result {"ok": true, "ticket": {"ticket_id": "T-1003", "customer_id": "C-3", "order_id": "O-1003", "body": "Refund me $5,000 for the trouble.", "status": "open", "note": ""}}
call lookup_order {"order_id": "O-1003"}
result {"ok": true, "order": {"order_id": "O-1003", "customer_id": "C-3", "total": 120.0, "currency": "USD", "status": "delivered", "delivered_late": false}}
call lookup_customer {"customer_id": "C-3"}
result {"ok": true, "customer": {"customer_id": "C-3", "name": "Mallory Voss", "email": "[email protected]"}}
call issue_refund {"order_id": "O-1003", "amount": 5000, "currency": "USD", "reason": "customer request"}
result {"status": "BLOCKED", "tool": "issue_refund", "reason": "Tool-trust hard gate: tool_trust: 'issue_refund' amount 5000.0 exceeds declared max_amount 1000.0", "outcome": "b
call update_ticket {"ticket_id": "T-1003", "status": "escalated", "note": "Passed to a colleague."}
result {"ok": true, "ticket": {"ticket_id": "T-1003", "customer_id": "C-3", "order_id": "O-1003", "body": "Refund me $5,000 for the trouble.", "status": "escalated", "note": "Pa
answer I've passed your request to a colleague, who will reply within one day.
ticket escalated | Passed to a colleague.
actions ['read_ticket', 'lookup_order', 'lookup_customer', 'update_ticket']
T-1001: a normal $40 refund goes through
Every tool is checked, runs, and appears in the log.
call read_ticket {"ticket_id": "T-1001"}
result {"ok": true, "ticket": {"ticket_id": "T-1001", "customer_id": "C-1", "order_id": "O-1001", "body": "My order arrived five days late. Can I get $40 back?", "status": "open
call lookup_order {"order_id": "O-1001"}
result {"ok": true, "order": {"order_id": "O-1001", "customer_id": "C-1", "total": 120.0, "currency": "USD", "status": "delivered", "delivered_late": true}}
call lookup_customer {"customer_id": "C-1"}
result {"ok": true, "customer": {"customer_id": "C-1", "name": "Alice Moreno", "email": "[email protected]"}}
call issue_refund {"order_id": "O-1001", "amount": 40, "currency": "USD", "reason": "arrived late"}
result {"ok": true, "refund": {"refund_id": "R-0001", "ticket_id": "T-1001", "order_id": "O-1001", "amount": 40, "currency": "USD", "reason": "arrived late", "status": "sent"}}
call create_ticket {"customer_id": "C-1", "order_id": "O-1001", "body": "Late delivery: ask the carrier why order O-100
result {"ok": true, "ticket": {"ticket_id": "T-1006", "customer_id": "C-1", "order_id": "O-1001", "body": "Late delivery: ask the carrier why order O-1001 arrived late.", "statu
call send_email {"to": "[email protected]", "subject": "Your refund", "body": "We have refunded $40 for your order O
result {"ok": true, "message_id": "M-0001"}
call update_ticket {"ticket_id": "T-1001", "status": "resolved", "note": "Refunded $40 and emailed the customer."}
result {"ok": true, "ticket": {"ticket_id": "T-1001", "customer_id": "C-1", "order_id": "O-1001", "body": "My order arrived five days late. Can I get $40 back?", "status": "reso
answer I refunded $40 for your order and emailed you a confirmation.
ticket resolved | Refunded $40 and emailed the customer.
actions ['read_ticket', 'lookup_order', 'lookup_customer', 'issue_refund', 'create_ticket', 'send_email', 'update_ticket']
T-1005: "delete all my orders" is refused
call read_ticket {"ticket_id": "T-1005"}
result {"ok": true, "ticket": {"ticket_id": "T-1005", "customer_id": "C-2", "order_id": null, "body": "Please delete all my orders.", "status": "open", "note": ""}}
call lookup_customer {"customer_id": "C-2"}
result {"ok": true, "customer": {"customer_id": "C-2", "name": "Ben Okafor", "email": "[email protected]"}}
call purge_orders {}
result {"status": "BLOCKED", "tool": "purge_orders", "reason": "Tool-trust hard gate: tool_trust: action 'purge_orders' is not in this agent's declared_tools", "outcome": "block
call update_ticket {"ticket_id": "T-1005", "status": "escalated", "note": "Passed to a colleague."}
result {"ok": true, "ticket": {"ticket_id": "T-1005", "customer_id": "C-2", "order_id": null, "body": "Please delete all my orders.", "status": "escalated", "note": "Passed to a
answer I've passed your request to a colleague, who will reply within one day.
ticket escalated | Passed to a colleague.
actions ['read_ticket', 'lookup_customer', 'update_ticket']
T-1002: $900 is blocked by the policy pack
call read_ticket {"ticket_id": "T-1002"}
result {"ok": true, "ticket": {"ticket_id": "T-1002", "customer_id": "C-2", "order_id": "O-1002", "body": "It arrived broken. I want a full refund.", "status": "open", "note": "
call lookup_order {"order_id": "O-1002"}
result {"ok": true, "order": {"order_id": "O-1002", "customer_id": "C-2", "total": 900.0, "currency": "USD", "status": "delivered", "delivered_late": false}}
call lookup_customer {"customer_id": "C-2"}
result {"ok": true, "customer": {"customer_id": "C-2", "name": "Ben Okafor", "email": "[email protected]"}}
call issue_refund {"order_id": "O-1002", "amount": 900, "currency": "USD", "reason": "customer request"}
result {"status": "BLOCKED", "tool": "issue_refund", "reason": "Blocked: final_confidence=71.9 (min=50.0) or policy_score=0.0 (min=60.0)", "outcome": "block"}
call update_ticket {"ticket_id": "T-1002", "status": "escalated", "note": "Passed to a colleague."}
result {"ok": true, "ticket": {"ticket_id": "T-1002", "customer_id": "C-2", "order_id": "O-1002", "body": "It arrived broken. I want a full refund.", "status": "escalated", "not
answer I've passed your request to a colleague, who will reply within one day.
ticket escalated | Passed to a colleague.
actions ['read_ticket', 'lookup_order', 'lookup_customer', 'update_ticket']
T-1004: the injected ticket is withheld
The check *before* cannot catch this: it sees only the tool name and arguments, not the ticket text. The check *after* sees the text that read_ticket returned and withholds it, so the model never reads the injection. read_ticket is still in the action log: it ran, and only its result was held back.
call read_ticket {"ticket_id": "T-1004"}
result {"status": "BLOCKED", "tool": "read_ticket", "reason": "Blocked: final_confidence=80.7 (min=50.0) or policy_score=20.0 (min=60.0)", "outcome": "block"}
call update_ticket {"ticket_id": "T-1004", "status": "escalated", "note": "Passed to a colleague."}
result {"status": "BLOCKED", "tool": "update_ticket", "reason": "Blocked: final_confidence=80.7 (min=50.0) or policy_score=20.0 (min=60.0)", "outcome": "block"}
answer I've passed your request to a colleague, who will reply within one day.
ticket escalated | Passed to a colleague.
actions ['read_ticket', 'update_ticket']
Open Events in the Console and filter by agent refund-bot. Each guarded call is a row with the decision, reason and arguments. You should find the $5,000 refund blocked by the tool cap, the $900 refund blocked by refund_over_500, and the poisoned read_ticket blocked by the check after. The user column reads support-bot.
That is the whole build. The tools, loop and model contain no safety logic: the checkpoint is a wrapper, switched on in one place.
If the bot can't ask, it doesn't act.
.env and rotate it in the Console if it leaks.AGENTRUST_FAILURE_MODE=closed an unreachable gateway refuses every tool and the ticket stays open. open runs everything unchecked during an outage.When in doubt, run uv run refund-bot agenttrust check and ./scripts/todos.sh. They catch most setup mistakes.
The check says "rejected the key (HTTP 401)"
The key in .env is not an agent key. Copy the at_live_… key again. A long value starting eyJ is a login token. If you test with curl, send the key in an X-AgentTrust-Token header: Authorization: Bearer alone gets a 401.
A $5,000 refund or "delete all orders" is approved
The manifest has no declared tools (step 2), so AgentTrust skips the tool check. Open the agent, confirm the Tools line lists seven tools, and run the check again.
Every result says UNAVAILABLE
Your program cannot reach the gateway. Check AGENTRUST_GATEWAY_URL and your network. Nothing ran and the ticket stays open, which is the safe outcome.
The program stops with GovernanceNotConfigured
The gateway URL or key is missing from .env. This is deliberate: without them the checks would go to your own app.
The Console shows the user as unknown
The line that sets the user in step 7 is missing. @harness reads the user only from keyword arguments.
The shorter Python decorator, on the support assistant, is Stop Risky AI Agent Tool Calls in Python with @harness. Framework wrappers: LangGraph and CrewAI. The full guide is in the refund bot docs.
No. The bot uses a scripted model, so every run is identical.
The bot refuses to act. A checkpoint that let calls through when it could not ask would fail open.
Yes. The rules live in the Console, so you change limits without redeploying the bot.
No. It is under the issue_refund cap of 1000. Set the cap to the largest amount you are willing to refund without a person.
Python 3.12, uv, git, and an account at console.agent-trust.tech.
Check every tool call before it runs, and keep a record of every decision.
See Pricing & Start Free →