Building a guardrail
The guardrail checks every tool call an LLM agent proposes, before it runs, and answers allow, confirm (ask
the person) or refuse, naming the rule that decided. Nothing in a decision is guessed by a language model: the same
call, conversation and rules always give the same answer and the same decision_id.
You can build one two ways, from the same steps: in Python, or by asking your own AI assistant through stalwart mcp.
What is guaranteed, and what is not
Guaranteed:
- Every tool call is checked before it runs, by the same rules, and the same call, context and rules always give the same answer.
- A tool outside your list never runs; a call whose values break the tool's own description never runs.
- A write no rule allowed never runs without a person's yes, and that yes covers exactly the call shown.
- A value in a write that nobody said and no tool returned is asked about.
- Nothing in a decision is guessed by a language model.
Not guaranteed:
- It judges tools, arguments, labels, history and rules, not what a reply means.
- It cannot tell a right value from a wrong one that someone did say.
- Your rules are only as complete as you wrote them: the bug hunter looks for holes.
- A tool whose name and description hide what it does may be classed wrongly: mark it.
- Calls made outside the guardrail are not checked.
Step by step, in Python
- Install.
pip install stalwart-agents, and activate the licence (stalwart licence activate). The checks are answered by the licensed runtime on your machine; if it does not answer, every call is blocked, never allowed. Give it your tools. The list you hand the model is enough: OpenAI or Anthropic tool definitions, an MCP server's
tools/list, or LangChain tools. An LLM given no tools makes no tool calls, so there is nothing to check.from stalwart_agents.guardrails import Guardrail rail = Guardrail(tools=tools)Say which tools write. Each tool is a read, a write or one that sends out. Most are worked out from the name, the description and the arguments; a tool nobody can tell is treated as a write. Write the ones you know into
tool_kinds.yaml(Tool kinds) and passtool_kinds="tool_kinds.yaml".- Write your rules. A rule sheet in plain YAML (The rule sheet):
rules="rules.yaml". - Choose the checks. Every check is on by default;
checks=[...]turns on only those listed (The checks). The checks that cannot be switched off always run. Check each call before running it.
from stalwart_agents.guardrails import CallRefused context = {"turns": turns, "results": results, "session": sid} try: verdict = rail.check_call(call, context) # a refusal raises CallRefused if verdict.decision == "confirm" and ask_person(verdict.confirmation["sentence"]): verdict = rail.check_call(call, {**context, "confirmation": rail.approve(verdict.confirmation)}) if verdict.proceed: results[call["id"]] = run_tool(call) # your code runs the tool, never the guardrail except CallRefused as refused: tell(refused.verdict.reason)A call changed after the person's yes is asked about again.
Try it. Dry-run calls you expect to pass and to be stopped, search the wordings the reader checks (The wording search), and replay your agent's past calls (Evaluating).
- Connect it where your agent runs: plain Python, the MCP gateway, Guardrails AI or NeMo Guardrails (Connecting it).
Build it with your AI assistant
stalwart mcp is an MCP server on your machine. Add it to your MCP client (Claude Code, Cursor, VS Code) and ask
your assistant to build the guardrail: its build-guardrail prompt walks it through, in order:
- reading your tools:
catalogue_from_server; - their kinds:
writes_file, and theclassify-toolsprompt, which lets your assistant propose kinds throughset_tool_kinds(recorded assource: assistant, listed apart); - drafting a rule sheet from your policy in plain words, with the rule sheet reference as its resource;
compile_rulesandlint_rules;- dry runs:
check_call; search_wordingsandhunt;- the snippet for where it will run:
snippet.
Your own assistant drafts the rules; our server never calls a model. What it drafts is compiled, linted and hunted, and you read the sheet before it is used.
$ claude mcp add stalwart -- stalwart mcp
Answers and what they do
| Answer | What happens | Python | Guardrails AI |
|---|---|---|---|
| allow | the call may run | verdict.proceed is true |
PassResult |
| confirm | the person is asked; on their yes the same call passes | ConfirmationNeeded, or action == "ask" |
FailResult with stalwart_confirm |
| refuse | nothing runs; the rule's sentence says why | CallRefused, or action == "refrain" |
FailResult with the sentence |
on_fail chooses what a failure does: {"refuse": "exception" | "refrain" | "noop", "confirm": "ask" | "exception" |
"refrain"}. noop records a failure and lets the call run, only for a check that may be switched off.
Why a call was stopped
Each decision is kept, by default, in a JSON-lines file a day under ~/.stalwart/decisions/ (record=False keeps
nothing; STALWART_DECISIONS moves it): the decision_id, the answer, the check and its rule, the tool, the argument
names and digests, where the tool's kind came from and what the check read. Never an argument value and never a word
of what the person said. explain_decision(decision_id) in stalwart mcp reads it back.