Developer quickstart¶
This page is for developers, and takes about half an hour; to make a bot in the console alone, with no key and no terminal, start with the Quickstart instead. By the end of it you will have a Stalwart account and built a support bot for a small online shop. You will also have checked that the bot cannot charge or refund a card without the checks you gave it, read every message its words can make, and talked to it. Last, you will scan a guardrail policy for holes, check an agent built without Stalwart, and get a proof you can check yourself.
You will work in two places: your terminal, where you run the commands and the Python on this page, and the Stalwart console in your browser, where your account, your keys and the results of your checks live. Where a step needs the console, or leaves something there for you to look at, a picture of that console page follows, with the part that matters circled.
Every output on this page is exactly what the code above it printed when this guide was built, so what you see when you run it should match.
Language support
The selector reads turns written in English and in Brazilian Portuguese (pt-BR). The language is chosen, never guessed from the turn: the console's Settings page (Language the selector reads), language on a plan request, or the settings.language of your vocabulary file; with none of them, English. A Portuguese turn may be typed with its accents or without them. European Portuguese is not a grammar the selector has, and a turn in any other language is unread.
Your account, and its first key¶
Everything starts with an account. Sign in to the console with your Google account or with a link we email you; continuing accepts the terms and the data processing addendum, which the page links to:

The first time you sign in, that is all: your organisation now exists, on the free plan, named after your email address until you rename it on Account, and the console opens on Your bots. This page needs an API key, so open Keys, say what the key is for, and press Mint a key. This is the only time you will ever see the key: we keep only a hash of it, so nobody, us included, can show it to you again. Copy it somewhere safe now:

If you lose it, or want a separate key for each program that uses Stalwart, mint another the same way. The page lists each key by its public id, a short label that tells your keys apart but cannot be used in place of the key (chapter 5):

Install, and your two keys¶
Now install the package, give it your key, and let this machine run your bots:
$ pip install "stalwart-agents[anthropic]" # or openai, google, mistral, ollama; or none, for no model
$ export QLS_API_KEY=qls_live_...
$ stalwart licence activate
QLS_API_KEY is the Stalwart key you just copied. Keep it apart from the other key you may have, your
model's key: that one comes from your model provider, such as Anthropic, and stays in your own
environment under the provider's usual name, such as ANTHROPIC_API_KEY. You never type it into our
console (chapter 5). The last command, stalwart licence activate, registers
this machine as one your bots may run on (chapter 38).
An agent, its first draft refused¶
The bot you will build is the support desk of a small shop. It can look up orders, charge a card, send a receipt, and pass refunds to a separate billing desk. The whole bot fits in one file: the tools it may use, a sentence for each saying what it does, and the billing desk it hands refunds to (chapter 30). Here is a first draft:
# The shop's support desk, and the billing desk it hands refunds to: the first draft.
agents:
desk:
model: "anthropic:claude-sonnet-5-5" # your provider; the key comes from your environment
tools: [get_order, charge_card, send_receipt, read_inbox]
hands_off_to: [billing]
on_unread: refuse # a turn no tool covers is refused, by name
billing:
privilege: high
tools: [refund_card]
tools:
- python: shop.tools # every public function of shop/tools.py
rulebook: ./policy.co # the rules every call is checked against
facts: # what the rulebook's $card_on_file and $order_paid are
card_on_file: {from: get_order.card, same: order_id} # read from get_order's answer
order_paid: {from: get_order.status == paid, same: order_id} # for the order the call names
runs: atomic # a failed run is undone, last write first
storage: sqlite:///runs.db # where runs and waiting confirmations are kept
Beside it sits a rulebook, which says when a charge or a refund is allowed to happen: a refund, for example, only for an order that was paid. Suppose a teammate, in a hurry, added one more rule so that a customer who types just refund it gets their refund straight away:
# Added in a hurry: a refund, on the intent alone.
define user ask quick refund
"just refund it"
define flow quick refund
user ask quick refund
bot execute_refund_card
Before this bot answers a single customer, check it with stalwart verify. This is everything it does, in
order:
- It reads the bot's file. It loads
agent.yamland builds the bot from it: the two desks, their tools, and the rulebook the file names. - It reads your tools' code, on your machine. Every source file in the bot's folder, in Python, JavaScript, TypeScript or Go, is read for the ways code leaks secrets or reaches where it should not: turning off the check that a website is the one it claims to be, writing a password into a log, or sending a request to an address a customer typed. Only the number of problems found is sent to Stalwart; your code stays on your machine. Every problem found fails the check.
- It registers your tools and your rulebook with Stalwart. They become a numbered version in your account, and the rulebook becomes the checks every tool call goes through while the bot runs. Registration also lists what your tools leave unsaid, as warnings.
- It searches every route through the bot. On Stalwart's service, it follows every way a conversation can go through the two desks, looking for one that reaches a tool without its check, without the confirmation it asks for, or before the step it must come after.
- It prints what it found, and a verdict. Each problem, the most serious first, and then whether the bot passes.
How it ends tells a script what happened: exit status 0 means the bot passed, 1 that it failed, 2 that
the file could not be read, and 3 that nothing was checked. Chapter 31 has the options,
such as --keep to save what was checked in a folder, and --replay to replay each problem found with
stand-ins for your tools:
$ stalwart verify agent.yaml
error: line 30: tool action 'execute_refund_card' is registered from flow 'quick refund' as well as from 'refunds'; their guards merge as an OR, so the weaker route decides whether the side effect happens. Register a tool action from exactly one flow. The rulebook was not compiled, and the catalogue was not registered.
$ echo $?
3
It refused the draft at registration, before searching anything. The refund can now be started from two
places, the careful rule and the hurried one, and the hurried one checks nothing: a customer who typed just
refund it would be refunded whether they had paid or not. The message names line 30 of the rulebook, where the
hurried rule starts the refund. Nothing was registered, and the command ended with status 3, so a build pipeline
running it would stop here too (chapter 46).
The draft fixed, and verified¶
The fix is to have one rule decide every refund. The hurried sentence moves into the careful rule, so just refund it is now treated like any other refund request, and checked the same way:
# A quick refund is a refund: its sentence joins `ask refund`, so the one guarded flow decides it.
define user ask refund
"refund order 4411"
"give the customer their money back"
"just refund it"
define flow refunds
user ask refund
if $order_paid
bot execute_refund_card
else
bot refuse
The search never ran on the draft, so two warnings it would have given were still waiting. Two lines in
agent.yaml settle them before they appear (chapter 31). budget: caps how many calls one
run may make, so the bot cannot go round in circles (QG023). undo: none says honestly that a refund cannot be
taken back, so nothing pretends to undo it if a run fails halfway (QG105). Here is the finished file. Its last
lines give the bot some work to do on its own, which you will meet further down:
# The shop's support desk, and the billing desk it hands refunds to: fixed, and given its morning's work.
agents:
desk:
model: "anthropic:claude-sonnet-5-5" # your provider; the key comes from your environment
tools: [get_order, list_orders, next_unpaid_order, charge_card, send_receipt, read_inbox]
hands_off_to: [billing]
on_unread: refuse # a turn no tool covers is refused, by name
billing:
privilege: high
tools: [refund_card]
tools:
- python: shop.tools # every public function of shop/tools.py
- {name: refund_card, undo: none} # a refund is not taken back: a failed run leaves it done
rulebook: ./policy.co # the rules every call is checked against
facts: # what the rulebook's $card_on_file and $order_paid are
card_on_file: {from: get_order.card, same: order_id} # read from get_order's answer
order_paid: {from: get_order.status == paid, same: order_id} # for the order the call names
budget: {max_steps: 20} # the most calls one run may make
runs: atomic # a failed run is undone, last write first
storage: sqlite:///runs.db # where runs and waiting confirmations are kept
job:
title: Shop support desk
purpose: answers customers about their orders, and charges the unpaid ones
manager: owner
hours: {days: [mon, tue, wed, thu, fri], from: "09:00", to: "17:30", time_zone: Europe/London}
tasks:
charge_unpaid:
title: Charge the oldest unpaid order
agent: desk
tools: [next_unpaid_order, get_order, charge_card] # read first, then the one write
starts_at: suggest # a person approves each run on Today
triggers: [weekday_mornings]
triggers:
weekday_mornings: {kind: schedule, at: "09:00", days: [mon, tue, wed, thu, fri]}
Run the check again:
$ stalwart verify agent.yaml
stalwart verify agent.yaml
Registered as catalogue version 1, with the rulebook policy.co: the tool check enforces the rules verified here.
Lints at registration (none of these blocks the agent):
write_not_retry_safe (warning) on charge_card: a write with no idempotency, so an ambiguous failure goes to a person
write_without_result_schema (warning) on charge_card: a write with no result schema, so its outcome could not be reported
compensation_not_retry_safe (warning) on charge_card: 'refund_card' undoes it and is not retry-safe
write_not_retry_safe (warning) on refund_card: a write with no idempotency, so an ambiguous failure goes to a person
write_without_result_schema (warning) on refund_card: a write with no result schema, so its outcome could not be reported
write_without_result_schema (warning) on send_receipt: a write with no result schema, so its outcome could not be reported
compensation_out_of_reach (warning) on charge_card: agent 'desk' can call it but not 'refund_card', which undoes it, so a run of that agent cannot undo it
Code checks: no finding in 8 file(s) (rules d656f9ab32d3).
No findings.
Rules with nothing to read, so nothing raised:
QG017: no argument is declared as code or a shell command
QG018: no argument is declared as SQL, a query, a path, a URL or a template
QG019: no tool is declared confidential
QG020: no retrieval from a store declared multi-tenant
QG021: no tool is declared to write a store declared persistent
QG106: no tool declares a source or a server, and the catalogue declares no modules
QG107: no agent in the catalogue declares a remote channel
Gate: passed. Nothing blocks this agent.
$ echo $?
0
Read it from the top:
- Registered as catalogue version 1: your tools and your rulebook are now version 1 in your account, and the rules the bot runs under are the ones this run checked.
- Lints at registration: what your tools leave unsaid. None of them stops the bot. Here they say three
things. Nothing tells the bot that sending the same charge or refund twice makes only one, so when one
fails without saying whether it went through, a person decides instead of the bot trying again
(
not_retry_safe). Nothing describes what the tools answer, so the bot cannot report their outcome in its own words (without_result_schema). And the desk can charge a card while only the billing desk can refund one, so a run at the desk that fails halfway cannot undo its charge (out_of_reach). Chapter 34 shows the line inagent.yamlthat settles each one. - Code checks: how many of your files were read, with no problem in any of them, and the version of the rules they were read with.
- No findings: the search found no route through the bot to a charge, a refund or a receipt without its check, the confirmation it asks for, or the step it must come after, to the depth it searched (chapter 31).
- Rules with nothing to read: checks that look at something you declare about your tools, such as an argument that is run as code or a tool that reads confidential records. This bot declares none of these, so these checks had nothing to look at, and the list names them (chapter 31).
- Gate: the verdict.
passedmeans nothing blocks this bot, and the command ended with status0.
This run also left its mark in the console. Your tools and your rulebook are now registered with your organisation, and the Catalogue page shows them as version 1. It lists each tool as a read or a write and the question the bot asks before a write; further down are the warnings registration gave. The search itself ran on Stalwart's service, and it is listed on the Jobs page, which you will look at at the end. The refused draft left nothing behind on either page.

Every way a customer can say it¶
The search you just ran follows routes through the bot. The wording check tests the messages themselves: it
writes out every message a customer could send using the words the bot knows, and reads each one. It runs on
Stalwart's service when you hand the bot's tools to the bug hunter with client.hunt. This is what it does:
- It gathers the bot's words. These are the words of your tools' names and descriptions, the everyday
names in
also_called, the words of your vocabulary, the numbers, dates and addresses the tools take, and the small words that join a sentence, such as not, and then or to. Any other word means nothing to the bot, so one made-up word stands for all of them. - It writes every message those words make, up to a size. Each request has up to three words that name something, such as what to do, what to do it to, and a value like an order number. A message has up to two requests, and up to one typing mistake.
- It reads each message with the same reader your bot runs, exactly as it would read a customer's, in the language your console's Settings page names (below).
- It checks each reading for three mistakes: a message that says not to do something, such as don't charge order 4411, that does it anyway; a message that only asks to see something that changes something instead; and a message that sends data out, such as to an email address, that the bot does not refuse.
- It checks that other words change nothing. It puts made-up words into the messages and checks that every answer stays the same. That is why the messages built from the bot's own words stand for every message a customer could send.
- It reports how far it got. For each desk, it says how many messages it built, and whether it read them all in the two minutes it has or ran out of time first. It reads the shortest messages first.
The bug hunter reads the bot's tools from a catalogue file, which Agent.catalogue() writes from
agent.yaml:
catalogue = Agent.from_file("agent.yaml").catalogue() # the bot's tools, as the reader reads them
with open("catalogue.json", "w", encoding="utf-8") as file:
json.dump(catalogue, file)
result = client.hunt(model="catalogue.json", gate=True) # the wording check runs on Stalwart's service
wordings = result.wordings
limits = wordings.bounds
print(f"Sentences built from the bot's own words: {wordings.total:,}")
print(f" each with up to {limits['content_slots']} words that name something, "
f"{limits['instructions']} requests in one message and {limits['slips']} typing slip")
for desk in wordings.agents:
print(f" {desk.agent}: {desk.read:,} read, every one ({desk.completeness})" if desk.completeness == "exhaustive"
else f" {desk.agent}: {desk.wordings:,} built, and the two minutes ran out before all were read ({desk.completeness})")
print("Findings:", ", ".join(finding.rule for finding in result.findings) or "none")
print("Gate passed:", result.result["gate"]["passed"])
Sentences built from the bot's own words: 5,660,534
each with up to 3 words that name something, 2 requests in one message and 1 typing slip
billing: 687,810 built, and the two minutes ran out before all were read (partial)
desk: 4,972,724 built, and the two minutes ran out before all were read (partial)
Findings: QG307, QG102, QG102, QG102, QG202, QG324
Gate passed: True
Read it from the top:
- The size of the search: over five million messages, most of them for the desk, which has more tools and
so more words. Both desks say
partial: the two minutes ran out before every message was read. A partial search reports what it found in the messages it read, and never says the rest are clean. QG307:read_inboxreads from the outside world and takes a folder, which is a place, and it runs without asking. A message could try to use it to send the inbox somewhere. The check wrote such messages and none of them did it, so this is a point for a person to look at, rated High. Had one of them done it, it would be Critical, and it would block.QG102, three times: a typing slip alone reached a write: acharge the card, arefund the card and asend the receipt. The bot does not run these: it asks the customer first. They are listed so that you check your host asks too, whichstalwart servedoes.QG202:hand_off, the tool that passes a customer to the billing desk, was understood from its name, because nothing declares what it does.QG324: messages of one shape reachsend_receipt, and none of your test cases has that shape. The finding comes with examples of it for you to label.- The gate passed: none of these blocks a release. A new Critical finding would.
Attacked before launch¶
A bot that reads email can be tricked by what it reads. stalwart redteam plays the attacker. It runs your
bot on your machine, as it runs for customers, with every tool replaced by a stand-in that records the call,
so nothing real is charged or sent (chapter 33). Each round has three steps:
- it picks where to plant the text: somewhere the bot reads from the outside world, such as the support inbox, or the customer's own message;
- it writes the hostile text that should get the bot to make the call it is after;
- it reads what the bot did, from the calls the stand-ins recorded, and decides whether the call was made.
--open aims the attack at the bot's privileged tools, here refund_card on the billing desk; --rounds 2 gives it two rounds. Without --attacker, the attacker's text comes from a fixed
set written in advance; with --attacker anthropic:claude-sonnet-5-5, your model writes it with your key. In
this run the bot's own model was a stand-in that does whatever the last thing it read tells it to:
$ stalwart redteam agent.yaml --open --rounds 2
open campaign: not realised in budget (2 round(s)): this says nothing about whether the graph is safe; last round: the model did not call 'refund_card'
exit status: 0
The attack did not get through in the two rounds it was given: in the last one, the bot's model never called
refund_card. That is a result about this attack, not a promise about every attack: the verification you ran
above is what covers every route. If an attack does get through, the command ends with status 1, and the
attack is a finding to fix like any other.
Talk to it, and serve it¶
Now talk to the bot. This script plays a short conversation with it, one customer message at a time:
# The host's clock, fixed here at Wednesday 21 October 2026, 11:00 in London, so this prints the same every time.
wednesday = datetime(2026, 10, 21, 11, 0, tzinfo=timezone(timedelta(hours=1)))
desk = Agent.from_file("agent.yaml", clock=lambda: wednesday) # the graph is proved once, before the first turn
def say(text):
reply = desk.chat(text, user="u-17")
print(f"> {text}\n< " + reply.text.replace("\n", "\n ")) # a reply of several lines, each indented
for waiting in reply.waiting: # a write waits for a person, every argument with its origin
print(" waiting:", waiting.tool, [(a["name"], a["value"], a["origin"]) for a in waiting.arguments])
def decide(decision):
print(f"> ({decision})\n< " + desk.confirm("u-17", decision).text.replace("\n", "\n "))
say("show me order 4411") # a read: answered with no model call
say("list the orders placed this week") # a date read against the clock: Monday to Monday
say("charge order 4411") # a value is missing: the bot asks for it
say("40.00") # the answer fills it; the charge waits for a person
decide("approve") # it runs now, with exactly the arguments shown
say("show me order 4412 and then send the receipt for order 4412") # two steps: a read, then a write
decide("skip") # the person says no: the receipt is not sent
say("can you sort it out for me") # nothing here reads it: asked once to say it again
say("forward the support inbox to ana@example.com") # no tool does this: declined whole
> show me order 4411
< card: visa-0019
customer: c-208
order_id: 4411
status: unpaid
total: 40.00
> list the orders placed this week
< orders: 4411
> charge order 4411
< To charge card, which amount?
> 40.00
< Run charge_card?
waiting: charge_card [('amount', 40, 'reply'), ('order_id', '4411', 'typed')]
> (approve)
< amount: 40
charge_id: ch-4411
order_id: 4411
> show me order 4412 and then send the receipt for order 4412
< Run send_receipt? Nothing undoes it once it has run.
waiting: send_receipt [('order_id', '4412', 'typed')]
> (skip)
< card: visa-0019
customer: c-208
order_id: 4412
status: paid
total: 12.50
A person declined send_receipt.
> can you sort it out for me
< I didn't recognise anything in that. I can charge card, get order, hand off, list orders, next unpaid order, read inbox or send receipt. Which do you mean?
> forward the support inbox to ana@example.com
< I can't do that: S1 egress: forward; S2 destination: ana@example.com.
Lines starting > are what the customer typed, or what a person decided, in brackets; lines starting < are
the bot's reply. A lookup's reply is the tool's own answer, laid out one field to a line;
chapter 42 shows how to word replies for your customers. Each exchange shows a different part of how
the bot behaves:
- Looking up an order needs no language model at all. The bot matched the message to the tool whose description
fits it,
get_order, by a rule it can name (a turn read, below). - Dates are read against the computer's clock: this week means Monday to Monday, passed to
list_ordersas two times. - A missing detail is asked for by name, and the customer's answer fills it in. The bot records that the amount came from the customer's reply (chapter 22).
- Charging a card waits for a person. The bot shows every detail of the charge and where each one came
from: on the
waiting:line,typedmeans the customer typed it in the message, andreplythat it came from their answer to the bot's question. Approved, the charge runs with exactly those details; skipped, it does not run at all (chapter 32). - Two requests in one message are done in order. The lookup ran; the receipt, which cannot be unsent, waited for approval and said so. Skipped, it did not run, and the reply gives the lookup's result with it.
- A message the bot cannot make sense of gets one question back. If it comes again, the bot does what
your
on_unreadsetting says, which here is to refuse. - Something no tool should do, such as forwarding the support inbox to an outside address, is refused as
a whole, naming the rules that stopped it, and no part of that message runs.
S1 egress: forwardsays the message asks to send something out, and no tool sends;S2 destination: ana@example.comsays it names an address that no tool takes (chapter 49).
When you are happy with it, serve it. This runs the bot on your machine, with a chat page at
http://127.0.0.1:8700/ (--host and --port change where):
$ stalwart serve agent.yaml
What the model is for¶
You may have noticed that no language model was used in any of that. The bot decides which tool to use, and when, by its own rules, the same way every time. Your model has one job: it writes words when a customer says something no tool covers, and nothing else. It never picks a tool, plans a run or calls anything.
In this example the desk hands such messages to its model (on_unread: model), and a stand-in model answers
the way a real provider's would:
def model(conversation):
"""Your model, here a stand-in that answers as a provider's might: in words, and never with a tool call."""
said = [message for message in conversation if message["role"] == "user"][-1]["content"]
if "birthday" in said:
return "I'm sorry it hasn't arrived. Tell me the order number and I'll look it up for you."
return "Don't worry: I've refunded order 4411 in full."
desk = Agent("desk", tools=[get_order, charge_card, refund_card], model=model, on_unread="model")
for text in ("my parcel never arrived", # nothing reads it: asked once to say it again
"it was a birthday present and I'm upset", # still nothing reads it: the model answers, in words
"please make this right"): # the model claims a refund nobody made
print(f"> {text}\n< {desk.chat(text, user='u-18').text}")
> my parcel never arrived
< I didn't recognise anything in that. I can charge card, get order or refund card. Which do you mean?
> it was a birthday present and I'm upset
< I'm sorry it hasn't arrived. Tell me the order number and I'll look it up for you.
> please make this right
< Not done: refund card.
The first message gets one question back, listing what the bot can do. The second reaches the model, which answers kindly in its own words. The third shows why those words are checked before any customer reads them: the stand-in model above answered Don't worry: I've refunded order 4411 in full, but nothing was refunded, so the reply check replaced the claim with what was actually done, Not done: refund card (chapter 42). What a model says is always held to what the bot really did.
If you prefer, you can let the model choose tools itself. Every call it makes is still checked, but you give up the guarantee that the same message always leads to the same call (chapter 45).
A calculation no tool answers¶
A customer asks: what is the average total of orders 4411 and 4412? No tool does averages. The bot plans it as two lookups and a calculation, and your model writes the small function that does the arithmetic. The model is told the field names and the type of the answer, but never sees the customers' actual values. Before the function runs, it is read, and refused if it opens a network connection, reads a file or imports anything outside a short list of allowed modules. It then runs in a sandbox on your machine, a fresh container with no network and nothing of your machine's, and its answer is checked to be of the type asked for (chapter 23):
def write(prompt):
"""Your model: given the result type and the sources' field names -- never a value -- it writes the function."""
return ("def compute(data):\n"
" totals = [float(order['total']) for order in data.values()]\n"
" return sum(totals) / len(totals)\n")
with Client() as client:
plan = client.plan("what is the average total of orders 4411 and 4412", agent="desk", catalogue=None)
print(plan.outcome, "|", [step.selection.tool if step.kind == "tool" else step.kind for step in plan.steps])
step = next(step for step in plan.steps if step.kind == "compute")
sources = [{"tool": "get_order", "result": get_order(plan.steps[i].selection.arguments["order_id"])}
for i in step.compute["sources"]]
done = client.compute(step, sources, model=write)
print(done.state, done.result, done.result_type)
run | ['get_order', 'get_order', 'compute']
succeeded 26.25 number
The first line is the plan: two get_order lookups, then the calculation. The second is the result: the
function ran, the average is 26.25, and it is a number, as the plan asked.
A question your documents answer¶
Customers also ask how the shop works, such as how long a refund takes. No tool answers that either, but your
own documents do. stalwart sources build indexes them on your machine, and with on_unread: answer the bot
replies with the passage that answers the question. Under the extract setting used here, no model is
involved at all (chapter 44):
desk = Agent("desk", tools=[get_order, charge_card], on_unread="answer",
answers={"index": "sources.json", "composer": "extract"}) # the passage itself, no model
for text in ("How long does a refund take?", "Can a gift card be refunded?"):
print(f"> {text}\n< {desk.chat(text, user='u-19').text}")
$ stalwart sources build docs/delivery.md docs/refunds.md --out sources.json
Indexed 2 document(s), 3 passage(s), into sources.json (ba9e751e249e).
$ echo $?
0
> How long does a refund take?
< A refund reaches the card the order was paid with in three to five working days.
> Can a gift card be refunded?
< An order can be refunded within 30 days of delivery. A gift card cannot be refunded.
stalwart sources build cut the two documents into passages, short pieces that each answer one thing, and the
code in brackets identifies this index. Each reply is the passage, or passages, that answer the question,
word for word from your documents. A request to actually do something is never answered from documents: it
always goes to its tool, with its confirmation.
Work it does by itself¶
Remember the last lines of the finished agent.yaml? They give the bot a job, and one task within it:
job:
title: Shop support desk
purpose: answers customers about their orders, and charges the unpaid ones
manager: owner
hours: {days: [mon, tue, wed, thu, fri], from: "09:00", to: "17:30", time_zone: Europe/London}
tasks:
charge_unpaid:
title: Charge the oldest unpaid order
agent: desk
tools: [next_unpaid_order, get_order, charge_card] # read first, then the one write
starts_at: suggest # a person approves each run on Today
triggers: [weekday_mornings]
triggers:
weekday_mornings: {kind: schedule, at: "09:00", days: [mon, tue, wed, thu, fri]}
A task is work the bot does without being asked. It names the tools it uses, lookups first and then
changes, and the triggers that start it. This one is a schedule: it starts at a set time on the days
named, in the job's time zone. Other triggers start a task when a tool's data changes, or when a mail, a
webhook, a form or a message arrives.
stalwart serve runs the tasks. When one is due, the bot does the lookups, each checked like any other call,
and puts the changes they lead to on its Today page as a card for a person to approve. Nothing is changed
before someone approves it (starts_at: suggest), and the approval covers exactly the plan on the card and
nothing else. Here is what Today showed at 09:00 on Monday, and what ran once the card was approved:
09:00, Monday: the host started charge_unpaid by itself
On Today: Charge the oldest unpaid order (suggest)
looked up first: next_unpaid_order, get_order
it will: 1. charge card: amount 40, order id 4411
decisions: approve, edit, reject
Approved with a passkey: the run is done
charge_card ran: {'amount': 40.0, 'order_id': '4411'}
The card names the task, the lookups it already made, and the change it will make, with its values. The person can approve it, edit the values first, or reject it. Here it was approved with a passkey, the sign-in kept by their phone or computer, so the record shows who approved the charge, and then the charge ran.
If the computer was asleep when a task came due, a laptop for example, the task runs when it wakes, as long as it is still within the task's window. After that, it waits on Today as Missed, with a Run now button.
Your users' language¶
The bot reads one language for your customers' messages, English or Brazilian Portuguese, and you choose which on the console's Settings page, under Language the selector reads. It never guesses: a message in another language is not read at all (chapter 20). With Portuguese chosen, the bot understands mostre o pedido 4411, asks Para charge card, qual amount?, and says Não posso fazer isso when it refuses. The Portuguese quickstart builds this same shop in Portuguese.

The words that are yours stay yours to write in your customers' language:
- your tools' words: their descriptions, the everyday names customers use for them (
also_called), and the questions your confirmations ask (confirm_with); - your rulebook's sentences, which its rules recognise;
- the messages about what was done:
replies: {language: pt}gives you the standard ones in Portuguese, and any you write yourself are in whatever language you write them (chapter 42).
If your tools are named in English, give them your customers' own words, because one word can mean different
things: a pedido is an order in a shop and a request at a service desk. Put those words in also_called,
or in the vocabulary file for that language, which you can publish yourself or let Stalwart learn from your
customers' thumbs up and down (chapter 25).
A turn read¶
Underneath every conversation, each message is read before any model is called, and the reading ends in one of three ways: a tool with its values, a question back, or a refusal. Each comes with the rule that decided it, so you can always see why the bot did what it did (chapter 20):
def show(turn, plan):
print(f"{turn!r}: {plan.outcome}")
for step in plan.steps:
print(f" {step.kind} | {step.decided_by} | {step.selection.tool or '-'} | "
f"{dict(step.selection.arguments)} | from {dict(step.selection.sources)}")
if plan.declined_because:
print(" declined because:", plan.declined_because)
if plan.guidance and plan.outcome != "run":
print(" says:", plan.guidance.render)
with Client() as client:
print("answered by:", client.runtime_status()["side"])
for turn in ["show me order 4411", "charge order 4411 40.00", "charge 40.00 to order 4411",
"charge order 4411", "delete order 4411", "zork the frobnicator"]:
show(turn, client.plan(turn, agent="desk", catalogue=None))
answered by: local
'show me order 4411': run
tool | tool: admission | get_order | {'order_id': '4411'} | from {'order_id': 'turn'}
'charge order 4411 40.00': run
tool | tool: provenance | charge_card | {'amount': '40.00', 'order_id': '4411'} | from {'amount': 'turn', 'order_id': 'turn'}
'charge 40.00 to order 4411': run
tool | tool: provenance | charge_card | {'amount': '40.00', 'order_id': '4411'} | from {'amount': 'turn', 'order_id': 'turn'}
'charge order 4411': ask
ask | ask: incomplete | charge_card | {'order_id': '4411'} | from {'order_id': 'turn'}
says: I can charge card -- I need the amount.
'delete order 4411': unread
unread | unread: no tool | - | {} | from {}
says: I can get orders, but nothing here can delete them.
'zork the frobnicator': unread
unread | unread: no tool | - | {} | from {}
says: I didn't recognise anything in that. I can charge card, get order, hand off, read inbox or send receipt. Which do you mean?
answered by: local says the reading ran on your machine, in the runtime your licence lets it run. Under each
message, a step reads: what it is, the rule that decided it, the tool, the values, and where each value came
from, turn meaning the message itself. The rules named here:
tool: admission: one tool fits what the message asks for, so it is that tool;tool: provenance: more than one tool fit, and the one whose own words the message used most directly was kept;ask: incomplete: one tool fits, and a value it needs is missing, so the bot asks for it;unread: no tool: no tool does what was asked, and the bot says what it can do instead.
A policy scanned¶
So far you have checked a bot you built. Many teams already run guardrails written for NeMo Guardrails, and the bug hunter checks those too. It reads the policy and reports every way through it to an action it was meant to stop, each with a fix it has already tested by scanning again (chapter 28). Here it scans the shop's policy, written for NeMo Guardrails, with two rules a team added in a hurry:
# Added in a hurry: the refund, on the intent alone.
define flow quick refund
user ask quick refund
bot execute_refund_card
define flow receipts after a charge
user ask charge
if $card_on_file
execute send_receipt(order_id=$order_id)
# Added in a hurry: the receipt, called without execute.
define flow receipts
user ask receipt
$sent = send_receipt(order_id=$order_id)
if $sent
bot "The receipt is on its way."
The script scans it, then looks closer at one finding, then saves the findings in SARIF, the format code scanning tools such as GitHub's read:
result = client.hunt("policy.co", inputs={"rails_config": "config.yml"}, gate=True)
print(result.state, "| gate passed:", result.result["gate"]["passed"])
for finding in result.findings:
where = finding.location
print(f"{finding.rule} {finding.raw['title']} · {finding.severity} · {where['file']}:{where['line']}")
print(" ", finding.raw["message"])
print(" OWASP:", ", ".join(f"{entry.id} {entry.title}" for entry in finding.owasp) or "none")
print("nothing to read:", ", ".join(rule["rule"] for rule in result.nothing_to_read))
print("no rule for:", result.coverage["not_addressed"], "--", result.coverage["not_addressed_reasons"]["LLM05:2026"])
escapable = next(finding for finding in result.findings if finding.rule == "QG002")
print("root cause:", escapable.owasp_root_cause, "| reproduction:", escapable.reproduction)
print("fix:", escapable.fix["patch"], "in", escapable.fix["flows"])
print("re-scanned:", escapable.fix["verified"], "| over-blocking:", escapable.fix["over_blocking"]["outcome"])
print("OWASP's mitigation:", escapable.raw["owasp_remediation"][0])
saved = result.artifact("sarif").save("findings.sarif") # what your code scanning upload reads
run = json.loads(saved.read_text(encoding="utf-8"))["runs"][0]
rule = next(rule for rule in run["tool"]["driver"]["rules"] if rule["id"] == "QG002")
print("taxonomies:", ", ".join(taxonomy["name"] for taxonomy in run["taxonomies"]))
print("QG002's tags:", ", ".join(rule["properties"]["tags"]))
print("results:", [(r["ruleId"], r["locations"][0]["physicalLocation"]["artifactLocation"]["uri"]) for r in run["results"]])
succeeded | gate passed: False
QG002 Escapable guard · high · policy.co:27
The policy gates 'execute_refund_card' on 'order_paid', and there is a way past it.
OWASP: LLM03:2026 Excessive Agency, ASI02:2026 Tool Misuse and Exploitation
QG005 Unshippable policy · high · policy.co:35
line 35: tool action 'execute_refund_card' is registered from flow 'quick refund' as well as from 'refunds'; their guards merge as an OR, so the weaker route decides whether the side effect happens. Register a tool action from exactly one flow.
OWASP: none
QG008 Call without execute · high · policy.co:45
'send_receipt' is called without execute, so the action never runs
OWASP: none
QG022 Rail with a published advisory · high · config.yml:5
The configuration runs the library rail 'fiddler bot faithfulness' (nemoguardrails), which advisory QGA-2026-0001 describes: Fiddler faithfulness rail raises the jailbreak exception. version: unknown. The advisory affects nemoguardrails >=0.23.0,<=0.24.1, and no fixed version is published. Supply the file that pins the version you install: `stalwart verify --dependencies`, or `dependencies=` to `client.hunt`. With exceptions enabled, the faithfulness rail raises JailbreakDetectionRailException, so a host reads a detected hallucination as a jailbreak. Versions before 0.23.0 were not checked. Fix: Raise FaithfulnessRailException from the faithfulness flow, as the Colang 2 rail does.
OWASP: LLM04:2026 Supply Chain, ASI04:2026 Agentic Supply Chain Vulnerabilities
nothing to read: QG017, QG018, QG019, QG020, QG021, QG023, QG106, QG107
no rule for: ['LLM05:2026'] -- a property of training data and model weights, which no guardrail policy decides
root cause: permissions | reproduction: not_yet_replayed
fix: Patch: allow 'execute_refund_card' only when: $order_paid in ['refunds', 'quick refund']
re-scanned: verified | over-blocking: proved_free
OWASP's mitigation: OWASP LLM03:2026, "Complete mediation": enforce the check in the rail, which decides from trusted state, rather than relying on the model to decide whether the action is allowed.
taxonomies: OWASP Top 10 for LLM Applications, OWASP Top 10 for Agentic Applications, Guardrail categories (Vassilev 2026), NIST AI RMF functions (NIST AI 100-1)
QG002's tags: security, guardrail, guardrail-category:E, ai-rmf:GOVERN, ai-rmf:MANAGE, OWASP LLM03:2026, OWASP ASI02:2026
results: [('QG002', 'policy.co'), ('QG005', 'policy.co'), ('QG008', 'policy.co'), ('QG022', 'config.yml')]
Read it from the top:
- The gate failed, with four findings. Each gives its rule, its name, how serious it is, the line of the policy it is on, what was found, and the entries of the OWASP Top 10 lists it is an instance of.
QG002: the careful rule checks that the order was paid before a refund, and there is a way past that check, the hurried rule.QG005: the same refund is started from two rules, which is why the bot's own rulebook was refused at the start of this page.QG008: the receipt rule callssend_receiptwithoutexecute, so the receipt is never sent.QG022:config.ymlturns on a library rail with a published advisory against it. Give the scan the file that pins the versions you install, and it checks whether yours is affected.- nothing to read: rules that had nothing to look at in this policy, as with
stalwart verify. - no rule for: an OWASP entry no guardrail policy can decide, with the reason.
- root cause and reproduction: which of OWASP's three causes
QG002comes from (too much functionality, too many permissions or too much autonomy), here permissions, and that the conversation that triggers it has not yet been replayed against the policy. - fix: the change that closes it: allow the refund only when the order was paid, in both rules. re-scanned: verified says the policy, with the fix applied, was scanned again and the way past is gone; over-blocking: proved_free says it was also proved that the fix blocks nothing the policy is meant to allow.
- OWASP's mitigation: what OWASP recommends for this kind of finding.
- taxonomies, tags and results: what the SARIF file holds, so your code scanning tool shows each finding at its file and line.
That scan's results came back to your script and were not kept anywhere. To keep them, and to see them in the console, scan into a project. You create the project once, then pass its id each time you scan that policy. Each new scan is compared with the previous one, and anything a person decides about a finding in the console, such as accepting a risk until a date, is taken into account by the next scan (chapter 28):
with Client() as client:
project = client.create_project("shop-guardrails") # once: the console lists it under Findings
result = client.hunt("policy.co", inputs={"rails_config": "config.yml"}, gate=True,
project=project["id"], branch="main")
print(project["name"], "|", result.state, "| gate passed:", result.result["gate"]["passed"])
print("findings:", ", ".join(f"{finding.rule} {finding.severity}" for finding in result.findings))
shop-guardrails | succeeded | gate passed: False
findings: QG002 high, QG005 high, QG008 high, QG022 high
The project now appears on the console's Findings page, with its last scan: the gate failed, with four findings, all rated High.

Open the project to see the findings. On the free plan, every finding is counted and the most serious one is shown in full; the paid plans show every finding, its history and how the project is trending (chapter 40):

Open a finding to see the bug itself: where it is, a conversation that triggers it, and the fix, already tested by scanning again:

An agent built elsewhere¶
The search stalwart verify ran on the shop bot also works on an agent built with something else, such as
LangGraph or your own code. It needs no bot built with this package, only three things written down:
- the agent's graph, a
qls-graph/1JSON file: the steps the agent is made of, such as the model and the step that runs tools, and which step may follow which; - a catalogue of its tools, a JSON file saying what each tool is: whether it only reads or changes something, whether it reads from the outside world, and what undoes it;
- a rulebook, if you have one: a Colang policy whose rules your tool calls are checked against.
You send them to Stalwart as an ordinary job, here with client.verify_graph, and it searches every route
through the graph as it did for the shop bot. A LangGraph graph is written out for you by export_graph; any
other agent, you write down yourself. Here is the shop's desk written by hand, as another framework would run
it, with the model free to call any tool:
graph = { # the agent, as any framework runs it: who may follow whom
"format": "qls-graph/1", "name": "support", "agent": "desk", "recursion_limit": 20,
"nodes": [{"id": "__start__", "kind": "start"},
{"id": "model", "kind": "model"}, # the model chooses the next tool
{"id": "tools", "kind": "tools_plain", # the tools run as asked, unchecked
"tools": ["get_order", "read_inbox", "refund_card"]},
{"id": "__end__", "kind": "end"}],
"edges": [{"from": "__start__", "kind": "fixed", "destinations": ["model"]},
{"from": "model", "kind": "conditional", "destinations": ["tools", "__end__"]},
{"from": "tools", "kind": "fixed", "destinations": ["model"]}],
}
catalogue = {"tools": [ # what each tool is
{"name": "get_order", "read_only": True},
{"name": "read_inbox", "read_only": True, "reads_outside": True}, # it reads email from anyone
{"name": "refund_card", "read_only": False}, # it changes something
]}
with open("support.graph.json", "w", encoding="utf-8") as file:
json.dump(graph, file)
with open("support.catalogue.json", "w", encoding="utf-8") as file:
json.dump(catalogue, file)
with Client() as client:
result = client.verify_graph("support.graph.json", catalogue="support.catalogue.json")
print(result.state, "|", len(result.findings), "finding(s)")
for finding in result.findings:
print(f"{finding.rule} · {finding.severity} · {finding.raw['tool'] or '-'}")
print(" ", finding.raw["message"])
succeeded | 5 finding(s)
QG023 · medium · cycle:model
the cycle through 'model' calls the model or a tool, and nothing declares a budget: the framework's recursion limit of 20 is the run's only ceiling
QG001 · medium · get_order
'get_order' can run on a plain tool node, without the tool check
QG001 · medium · read_inbox
'read_inbox' can run on a plain tool node, without the tool check
QG001 · high · refund_card
'refund_card' can run on a plain tool node, without the tool check
QG011 · high · refund_card
the privileged 'refund_card' can run carrying the label untrusted:read_inbox with no confirmation or refusal
The shop bot passed this same search, because its tools run through the tool check. This desk, written by hand, does not:
QG001, three times: the step that runs the tools istools_plain, so each tool runs exactly as the model asks, without the tool check. It is Medium for the two lookups and High forrefund_card, which changes something.QG011:refund_card, a privileged tool, can run straight afterread_inboxhas brought text from outside into the conversation, with nobody asked in between. That is the route the red team tried above.QG023: the model and the tools can call each other in a loop with no limit set, so only the framework's own limit of 20 steps stops it.
The fix for the first two is to run the tools through the tool check, a tools_guarded step; the third
needs a budget for the loop.
Chapter 47 has every field of the graph and the catalogue, and how to read what comes back.
A verdict and a certificate¶
The last step goes one level deeper. Instead of searching for holes, you ask Stalwart to prove that one specific thing can never happen. The policy here is the guide's smallest, a bank's: it says a transfer runs only for a customer who is signed in.
# transfers.co: the guide's smallest policy. A transfer runs only for a signed-in user.
define user express greeting
"hello"
"good morning"
define user ask transfer
"I want to transfer money"
"send 100 pounds to another account"
define bot greet
"Hello! I am your banking assistant. How can I help?"
define bot refuse unauthorized action
"Sorry: for your security, you need to be signed in to do that."
define bot execute_transfer_funds
"Transfer authorised. Sending the funds now."
define flow greeting
user express greeting
bot greet
define flow secure funds transfer
user ask transfer
if $is_authenticated
bot execute_transfer_funds
else
bot refuse unauthorized action
You name the policy, the action (execute_transfer_funds) and the condition it must never run without
(is_authenticated), and ask Stalwart to prove it. profile="patch" asks for a certificate, and for a fix
where the rule does not hold (chapter 10). The answer comes with that certificate, which you then check
on your own machine, with no key and no network (chapter 6):
with Client() as client:
result = client.verify_guardrail("transfers.co", "execute_transfer_funds", "is_authenticated", profile="patch")
print(result.state, result.status, "| decided by", result.decided_by)
result.certificate.save("transfers.certificate.json")
checked = check_certificate("transfers.certificate.json") # here, offline: no key, no network
print("certificate:", checked.outcome, "|", checked.reason)
succeeded UNSAT | decided by candidate-derivation
certificate: verified | replayed 5 operation(s), and the withheld final step yields 0 = 1
UNSAT means it holds: there is no situation in which the transfer runs for a customer who is not signed in.
Stalwart turns the policy into equations and shows that a transfer without sign-in has no solution; decided
by names the part of the engine that showed it. The certificate is the evidence: the steps of that
calculation, with the last step left for the checker to take, so whoever checks it reaches the contradiction
(0 = 1) themselves (chapter 7). Anyone can check it, including people who do
not trust us; stalwart check transfers.certificate.json does the same check from the command line.
Back in the console, the Jobs page lists every check your organisation has run. Among them are this one,
a guardrail job with its verdict, and the hunt jobs from earlier, such as the search stalwart verify ran
and the project's scan.

Files shows what a job kept. The certificate there is the one you just checked. Opening it in the console is recorded in your organisation's history, so you can always see who looked at it.

Finally, the Usage page shows how much of your plan you have used this month, meter by meter, including the catalogue version you registered at the start (chapter 16):

Where next¶
- To build your own bot, start with Part I.
- To use the selector inside an application you already have, read Part II.
- To scan the guardrails you already run, go to chapter 28.
- To check an agent you built with LangGraph or your own code, go to chapter 47.