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:

Not guaranteed:

Step by step, in Python

  1. 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.
  2. 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)
    
  3. 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 pass tool_kinds="tool_kinds.yaml".

  4. Write your rules. A rule sheet in plain YAML (The rule sheet): rules="rules.yaml".
  5. 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.
  6. 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.

  7. 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).

  8. 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:

  1. reading your tools: catalogue_from_server;
  2. their kinds: writes_file, and the classify-tools prompt, which lets your assistant propose kinds through set_tool_kinds (recorded as source: assistant, listed apart);
  3. drafting a rule sheet from your policy in plain words, with the rule sheet reference as its resource;
  4. compile_rules and lint_rules;
  5. dry runs: check_call;
  6. search_wordings and hunt;
  7. 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.