Connecting it
The same checks, wherever your agent runs. In each, your code runs the tool, never the guardrail, and only after the guardrail allowed it.
In plain Python
from stalwart_agents.guardrails import Guardrail
rail = Guardrail(tools=tools, rules="compiled", tool_kinds="tool_kinds.yaml")
verdict = rail.check_call(call, {"turns": turns, "results": results, "session": sid})
call may be our shape ({"tool", "arguments"}), an OpenAI tool call, an Anthropic tool_use block or a LangChain
ToolCall. Run it only where verdict.proceed. See Building a guardrail for the confirmation round
trip.
The context the checks read:
| Key | What it is |
|---|---|
turns |
the person's turns, latest last: grounding and the checks of what was said read them |
results |
what tools returned, by call or tool, so a value in a write can be traced to them |
session |
keeps one conversation's history apart: the order rules and the session record read it |
confirmation |
the token approve gave for the person's yes |
language |
the turns' language, en or pt-BR, where it is not the guardrail's own (turns_in) |
labels, origins, plan, plan_token, usage |
trust labels, where values came from, an approved plan, and usage counts |
In front of MCP tools: the gateway
stalwart gateway sits between your agent and your MCP servers, and checks every tools/call before it reaches a
server. snippet("gateway", upstream=...) in stalwart mcp writes catalogue.json (your tools, their kinds and your
rules' overlay) and a gateway.yaml to start from:
$ stalwart gateway --config gateway.yaml
A call that needs a person becomes an MCP elicitation with the confirmation's sentence; a client that cannot be asked gets a refusal, never an unconfirmed write.
Inside Guardrails AI
$ pip install "stalwart-agents[guardrails]"
from guardrails import Guard
from stalwart_agents.guardrails import gai
guard = Guard().use(gai.ToolCall(tools=tools, rules="compiled", on_fail=gai.ask))
for call in response.tool_calls: # OpenAI or Anthropic shape, LangChain's, or a dict
outcome = guard.validate(gai.as_json(call), metadata={"stalwart": {"turns": turns, "results": results,
"session": sid}})
if outcome.validation_passed:
results[call.id] = run_tool(call)
elif (payload := gai.asked(guard)) is not None: # show payload["sentence"]; on the person's yes:
outcome = guard.validate(gai.as_json(call), metadata={"stalwart": {
"turns": turns, "results": results, "session": sid, "confirmation": gai.approve(payload)}})
Validate each call; do not wrap the model. Guardrails AI hands its validators the text a model wrote. When it wraps a model call, a validator receives only the last tool call's arguments, without the tool's name, on every version in the range we support (0.7 to 0.11). So each call the model proposes is validated on its own, before your code runs it.
The validators:
| Validator | Name | Runs |
|---|---|---|
ToolCall |
stalwart/tool_call |
every check |
GroundedArguments |
stalwart/grounded_arguments |
grounding |
PolicyRules |
stalwart/policy_rules |
your rule sheet |
AllowedTool |
stalwart/allowed_tool |
the allowed tools |
The checks that cannot be switched off run in each of them, so a write waits for a person in each.
What each answer does:
- allow:
PassResult;validation_passedis true. - refuse:
FailResultwith the rule's sentence, and the verdict (rule, decision id) understalwart_verdictin its metadata; your on-fail action applies:exceptionraises,refrainreturns nothing,nooppasses and records it. - confirm:
FailResultwhose metadata holdsstalwart_confirm, the payload to show the person. Withon_fail=gai.askthe call is refused until they answer,gai.asked(guard)reads the payload, andgai.Ask(show)also hands it to yourshow. A changed call is asked about again.
The context comes from metadata["stalwart"] (the keys above). A write checked with no turns fails, naming the
missing key: closed, never a silent pass. Our validators never change a call and never call a model, so fix and
reask change nothing: a refused call stays refused.
Several validators on one guard: Guard().use_many(theirs, ours) up to 0.10, Guard().use(theirs, ours) from 0.11.
Guardrails AI itself sends usage metrics unless ~/.guardrailsrc says enable_metrics=false. Nothing of ours does.
On NeMo Guardrails
$ pip install "stalwart-agents[nemo]"
from nemoguardrails import LLMRails, RailsConfig
from stalwart_agents.guardrails import Guardrail
from stalwart_agents.guardrails.nemo import register, snippet
rails = LLMRails(RailsConfig.from_path("config"))
register(rails, Guardrail(tools=tools, rules="compiled"))
print(snippet("2.x")) # or "1.0"
register adds one action, stalwart_check_tool (StalwartCheckToolAction in Colang 2). The snippet is the guard
shape the verifier proves: the tool action runs only inside the branch where the check allowed it; a call that waits
for a person asks them, and on their yes is checked again with the token; every other branch ends in the refusal
sentence. Rename the intent and the tool for your own; keep the shape.
What the action reads from NeMo's context, where its own arguments do not give it:
| Key | Read as |
|---|---|
stalwart_arguments |
the call's arguments (set it from the action that proposes the call) |
user_message, else last_user_message |
the person's latest turn |
stalwart_turns |
the person's turns, latest last, in place of the latest alone |
stalwart_results |
what tools returned |
stalwart_session |
the conversation (nemo where unset) |
stalwart_confirmation |
the token of the person's yes |
A write checked with no turn is refused, never allowed. In Colang 2, bot say waits for your client to report the
utterance finished before the flow goes on, as a chat loop does.
NeMo's own Guardrails AI integration validates only the user's and the bot's messages, never tool calls, so our
validators are used through register above, not through it.