Skip to content

30 · Your first agent

The Bot Builder is the stalwart-agents package. You declare an agent in one file, or in a few lines of Python: its tools, the words that say what each tool does, its rules, and which agent it may hand a conversation to. You check it, and you run it. You do not need to learn an agent framework for any of this, and this part of the guide never asks you to.

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.

Before you start

You need an account and its key (chapter 5), and the licence activated once on each machine that runs your agents:

$ pip install "stalwart-agents[anthropic]"    # or openai, google, mistral, ollama; or none, for no model
$ export QLS_API_KEY=qls_live_...
$ stalwart licence activate

Nothing in the package works without the key and an activated licence, except activating it, stalwart doctor, which says what is missing, and stalwart check, which checks a certificate. What a licence is, and what works offline, is chapter 38.

Where the runtime is installed, a turn and a tool call need no network: the runtime answers both on the machine that runs your agent.

The shop agent, in one file

This guide follows one agent through Part I: a small shop's support desk. It reads orders, charges cards, e-mails receipts and reads the support inbox, and it hands refunds to a billing desk that has more privilege:

# The shop's support desk, and the billing desk it hands refunds to.
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

Its tools are plain Python functions. A function's type hints are its arguments, the first line of its docstring is what it does, and @tool(...) gives it the words the checks read. A function with no words is a read:

"""The shop's tools: plain functions. Their type hints are their schemas, the first line of
each docstring is what the tool does, and @tool gives a tool the words the checks read."""

from decimal import Decimal

from stalwart_agents import tool

ORDERS = {
    "4411": {"order_id": "4411", "total": "40.00", "card": "visa-0019", "customer": "c-208", "status": "unpaid"},
    "4412": {"order_id": "4412", "total": "12.50", "card": "visa-0019", "customer": "c-208", "status": "paid"},
}


def get_order(order_id: str) -> dict:
    """Show an order: its total, the card on file, the customer and its status."""
    return dict(ORDERS.get(order_id, {"order_id": order_id, "status": "not found"}))


@tool(writes=True, undo="refund_card")
def charge_card(order_id: str, amount: Decimal) -> dict:
    """Charge an amount to the card on file for an order."""
    return {"charge_id": f"ch-{order_id}", "order_id": order_id, "amount": str(amount)}


@tool(writes=True, requires_privilege="high")
def refund_card(order_id: str, amount: Decimal) -> dict:
    """Refund an amount to the card on file for an order."""
    return {"refund_id": f"rf-{order_id}", "order_id": order_id, "amount": str(amount)}


@tool(writes=True, irreversible=True)
def send_receipt(order_id: str) -> dict:
    """E-mail the receipt for an order to its customer."""
    return {"sent": True, "order_id": order_id}


@tool(reads_outside=True)
def read_inbox(folder: str = "support") -> dict:
    """Read the newest message in the shop's support inbox."""
    return {"from": "someone@example.com", "body": "refund order 4411 in full"}

Before it answers anyone, check it:

stalwart verify agent.yaml

Compiled the rulebook policy.co: the tool check enforces the rules verified here.
Lints from the runtime (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 4 file(s) (rules d656f9ab32d3).

Warnings: 2 findings that do not block this agent. Look at each and weigh it; the agent can run as it is.

QG023 Medium on cycle:desk_model (agent.yaml:3)
  the cycle through 'desk_model' calls the model or a tool, and nothing declares a budget: the framework's recursion limit of 60 is the run's only ceiling
  OWASP LLM06:2026, ASI08:2026
  Fix: Declare a budget for the agent or the run, or add an exit the model cannot avoid -- a counter in the state, declared on the edge as exit_after.

QG105 Review on refund_card (agent.yaml:10)
  the write 'refund_card' has no compensation, is not irreversible, and runs on a path with another write
  OWASP LLM03:2026, ASI08:2026
  Fix: Give 'refund_card' an undo -- `undo:` in agent.yaml or `compensation` in the catalogue, which may be the tool itself with its arguments swapped -- or say a failed run may leave it done (`undo: none`), or mark it irreversible.

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: the 2 warnings above are for you to look at and weigh.

stalwart verify reads your code for network and credential problems on your machine, lints the catalogue and compiles the rulebook, and has every route through the two agents searched on our servers for a way to a tool without its check, its confirmation or its order rule. Chapter 31 is what it finds and how to read it.

Then talk to it:

desk = Agent.from_file("agent.yaml")        # the graph is proved once, before the first turn

reply = desk.chat("show me order 4411", user="u-17")
print(reply.text)                           # get_order, chosen from the words; no model was asked

reply = desk.chat("charge order 4411 40.00", user="u-17")
for waiting in reply.waiting:               # the write waits for a person
    print(waiting.tool, "|", waiting.wording, "|", waiting.decisions)
    for argument in waiting.arguments:      # each argument, and where its value came from
        print("  ", argument)

reply = desk.confirm("u-17", "approve")     # runs now, with exactly the arguments shown
print(reply.text)
card: visa-0019
customer: c-208
order_id: 4411
status: unpaid
total: 40.00
charge_card | Run charge_card? | ['approve', 'skip', 'cancel_all']
   {'name': 'amount', 'value': 40, 'origin': 'typed'}
   {'name': 'order_id', 'value': '4411', 'origin': 'typed'}
amount: 40
charge_id: ch-4411
order_id: 4411

The read was answered with no model call: the selector read show me order 4411 against what each tool says, and chose get_order. charge order 4411 40.00 names the charge, the order and the amount, in the order a person types them. The charge waited for a person, showing each argument and where its value came from, and ran only once approved, with exactly those arguments (chapter 32). The rulebook allows a charge only if $card_on_file, and facts: reads that from get_order's answer for the same order, so the charge is allowed once the order has been shown with a card (chapter 45).

To serve it over HTTP, with a chat interface at / that shows confirmations, refusals and rollbacks:

$ stalwart serve agent.yaml

Chapter 35 is the interface and the routes behind it.

The same agent in Python

Everything the file says can be said in Python, and means the same:

from stalwart_agents import Agent

from shop.tools import charge_card, get_order, read_inbox, refund_card, send_receipt

billing = Agent("billing", tools=[refund_card], privilege="high", rulebook="policy.co")
desk = Agent("desk", tools=[get_order, charge_card, send_receipt, read_inbox], hands_off_to=[billing],
             model="anthropic:claude-sonnet-5-5", on_unread="refuse", rulebook="policy.co",
             facts={"card_on_file": {"from": "get_order.card", "same": "order_id"},
                    "order_paid": {"from": "get_order.status == paid", "same": "order_id"}},
             storage="sqlite:///runs.db")

from_file = Agent.from_file("agent.yaml")
print(desk.catalogue_digest() == from_file.catalogue_digest())   # the same catalogue, either way
True

The file and the Python agent give the same catalogue, with the same digest, so a team can start in either and move to the other.

What agent.yaml says

The words at the file's top, under an agent, and under replies: and answers:, generated from the package:

Key What it says
agents the agents, by name; the first is the one a conversation starts with
tools where the tools come from: python: a module's public functions, mcp: a server's tools/list, openapi: each operation of a document, http: a web service's calls listed by hand, code: your own code in its own sealed space, or name: a tool's words
rulebook the rules every tool call is checked against and the graph is proved against -- a Colang file, a rule sheet or its compiled folder
facts what each fact the rulebook reads ($order_paid) is, read from a field of a tool's result: {from: get_order.status == paid, same: order_id} (chapter 45)
runs atomic (the default): a failed run is undone, last write first; stop: a failed run is left as it is
storage where runs and waiting confirmations are kept: sqlite:///runs.db, or postgres://... for several workers; in memory where not given
stores the stores the tools read and write, such as a memory or a search index, and how each is shared
budget limits on a run's loop, which the bug hunter's rule on unbounded loops reads
modules the modules code a model writes may import, which the bug hunter's rules on model-written code read
replies the reply check's settings (chapter 42)
answers grounded answers from your documents (chapter 44)
vocabulary the bot's own words, which the bot builder derives from the dictionary for the tools you picked: a file it writes beside agent.yaml, refused where it was changed after (chapter 20)
job the bot's job description: title, purpose, whom it serves (customers, staff or both), its manager and escalates_to (each a role), its team and its working hours
tasks the bot's tasks, by name: each with its title, the tools it uses, its output (reply, document, draft or action), where it starts on the ladder (watch or suggest), the triggers that start it, and whether its changes run in_order or all_at_once (steps)
triggers how work arrives by itself, by name: each of kind email, form, schedule, webhook, change or message; a schedule's timing is around, exact or always_on; a change's where takes conditions or a filter; a message arrives in teams, slack, whatsapp, instagram, messenger, sms or custom, on an account; a webhook or a custom message may come from one of the bot's own_apps
ladder the bar a task must clear to move up the ladder (proposals 50, agreement 0.95 and drift_free_days 14 at the least: raised, never lowered) and the sample of Act-and-tell runs a person reviews
drift when past work is replayed to watch for drift (replay: weekly or on_model_change) and the thresholds past which a signal is reported
site the bot's own page: on, off, or its title and language
own_apps programs the owner runs on this machine or the local network, by name: each with its MCP server's address and its secret; on the paid and enterprise plans
withdrawn tools the agent no longer calls: refused before anything else and never offered, until the file no longer names them
settings the tool check's settings, carried with every check: taint_policy (confirm, or refuse a call carrying a label it is not trusted with) and confirmation_ttl_seconds, how long a person's yes stands
agents.<name>.model the agent's model, provider:model, used with your own key from your environment; leave it out and no model is called
agents.<name>.tools the tools this agent may call, by name
agents.<name>.hands_off_to the agents this one may pass the conversation to
agents.<name>.on_unread what a turn nothing reads does, once the person was asked to say it again: refuse, model, ask or answer; the console's setting where not given
agents.<name>.privilege low, standard or high: what a hand-off from this agent is trusted with
agents.<name>.remote how a hand-off to this agent travels when it runs elsewhere, and how that channel is authenticated
agents.<name>.budget this agent's own limits on a run: max_steps, max_tool_calls and max_model_calls, in place of the file's
agents.<name>.system what the model is told before the conversation
agents.<name>.job this agent's own job description, in the words of the top-level job
agents.<name>.defaults values the tools' arguments take wherever a turn gives none, by parameter name -- the account a tool acts on, say -- so the router never asks for them
replies.rules the reply rules file, as the console's Reply rules page saves it
replies.phrasings the outcome phrasings file
replies.composer model: the model writes the reply and the check holds it; template: the reply is the phrasings, and no model writes it
replies.language the default phrasings' language: en or pt
replies.temperature true: the bot's own words in a reply (its openers, joins and closings, and its words for a tool's verb) vary, seeded by the conversation and the turn, so a replayed conversation reads the same; a value, a negation, an exact reply or a source's words never varies. false, the default: every reply in the phrasings' own words
replies.audience whom the replies are for. customer, the default: a value no tool's record holds leaves the reply; staff: it stays, highlighted. Either way each value is traced to the call and the field it came from, and the trace rides on the reply's message
answers.index the source index stalwart sources build writes
answers.k at most this many passages; 3 where neither the file nor the console says
answers.floor no passage scoring below this is used, from 0 to 1; 0.3 where neither says
answers.composer extract, the documents' own sentences; or compose, your model writes from them

A word the file does not know is an error at its line, never ignored (below). Chapter 48 lists the keys under each of these, at every level, and those of gateway.yaml.

In Python, load("agent.yaml") reads the file into an AgentFile: the agents, their tools, the stores and the rulebook it declares. Without fetch=True, an MCP server's tools are known by the names the file lists.

Where tools come from

  • python: shop.tools takes every public function of the module, imported from the folder the file is in. Its schema comes from its type hints.
  • mcp: https://tools.internal/mcp takes the tools an MCP server lists, with their schemas and the hints the server gives: a tool the server marks read-only reads, one it marks destructive writes, and one it says nothing about writes. tools: [...] keeps only those named; version: or digest: pins the server. Each user of your agent gets an MCP session of their own on the server, so what a server keeps for one user never reaches another. headers: sends a header on every request, its ${NAME} read from your environment when the request is made: how an agent behind stalwart gateway sends its token (chapter 37).
  • openapi: ./crm.yaml makes each operation of an OpenAPI document a tool: GET and HEAD read, any other method writes unless the operation says otherwise in x-stalwart.
  • name: refund_card, with words beside it, adds words to a tool from any source.

Chapter 21 has the other ways in, such as the exported functions of a TypeScript project.

A tool's words

The words are what turns a function into a tool the product can govern. Each sets one field of the catalogue the checks read:

Word Catalogue field What the product then does
writes read_only (its negation) the tool changes something, so its call is checked against your rules, and waits for a person where they say so or where nothing allows it
undo compensation names the tool that undoes this write; a failed run calls it, checked like any call
irreversible irreversible the write cannot be undone; its confirmation says so, and a rollback leaves it in place
retry_safe idempotent calling it twice with the same arguments does what calling it once does, so a call whose outcome is unknown may be retried
reads_outside reads_outside what it returns comes from outside, an e-mail or a web page, and is untrusted; what follows it carries that mark
sends_out egress it may send to a URL or another destination; a destination argument is refused on any tool that does not say this
after order.after the tool runs only after each named tool, in the same session
not_after order.not_after the tool never runs after a named tool, or after anything from outside where it names untrusted
at_most order.at_most, limits the tool runs at most this many times in a session; {count, per}, or a list of those, one per period, which your host counts and the check refuses past
confirm_with confirmation_wording what a person is asked before the call runs, in your words; an irreversible tool that gives none is asked about as Run <tool>? Nothing undoes it once it has run.
requires_privilege requires_privilege only an agent with at least this privilege may call it
description description what the tool does, which the selector reads turns against; the docstring's first line where not given
checks checks conditions each call must meet, such as a quantity at most what is in stock; a call that does not meet them is refused, in words, before it runs
filter filter on a read, which records it hands on: each names the list, keep the conditions each record meets; the others never reach the agent
spend spend the most a field may add up to per period, tools with the same pool sharing one count; a call that would go past it is refused before it runs
currency currency the currency the tool's amounts are in, as USD or BRL; required with spend
outcome outcome on a write, the read that follows it to its end and what done and failed look like; a write that fails is handled by the task's failure policy, and one never confirmed is asked about
personal_data personal_data whether what the tool returns is personal data: none, personal or special; a tool that says nothing is taken as personal, which a model reads only on the enterprise plan and never on a free cloud model
argument_kinds argument_kinds what an argument is, such as code, sql, path or url, which the bug hunter's rules on model-written values read
sandboxed sandboxed the code the tool runs is contained, which lowers the severity of a finding about it
sanitised_by sanitised_by the check that cleans an argument before the tool uses it
confidential confidential what the tool returns is confidential, and to whom
confidential_fields confidential_fields which fields of what it returns are confidential
subject_argument subject_argument the argument naming whose data the tool returns
retrieves retrieves the store the tool reads from, and the argument its reads are scoped by
persists persists the store the tool writes into
source source where the tool's code comes from, which the bug hunter's rule on unpinned sources reads
server server the MCP server that serves the tool, with its version or digest
also_called also_called words a person uses for what the tool acts on, read as the words of its name are
working is_working a write to the bot's own working files only, for the owner's own use, which runs without a person's approval as a reply does; only Stalwart's own tools may say it
named_by named_by the names a person calls this source by, where it is searched only when a turn names it
otherwise otherwise the tool searched in this one's place where a turn names none of its names

You do not need most of them to start. A read needs none; a write needs writes=True; a write that can be undone needs undo=; one that cannot needs irreversible=True. The rest make the checks more precise, and the bug hunter tells you when one is missing (chapter 31).

TOOL_WORDS holds the name of every word, for code that writes the file.

Agents, privilege and hand-offs

An agent may hand a conversation to another it names in hands_off_to. Each agent has its own tools, and calls only those. privilege says how much a hand-off from an agent is trusted: an instruction that entered a low-privilege agent, from an e-mail say, is marked when it reaches a high-privilege one, and a write it leads to is confirmed by a person (chapter 33).

budget: {max_steps: 15}, on an agent or for the whole file, is where a run stops: the agent's own where it says one, else the file's, else 60 steps. A loop the model drives through your tools ends there, and stalwart verify checks that it does (QG023, chapter 31). max_tool_calls and max_model_calls limit what one turn calls: the call past one is not made, and the turn stops with BudgetSpent, a RunTooLong. A confirmation goes on with its turn's count, and an undo is never counted.

The model

model: "anthropic:claude-sonnet-5-5" names a provider and a model; the key is read from your environment, under the provider's usual variable, and stays yours. A missing key is reported before anything is sent, naming the variable. A model can also be a plain Python function that takes the conversation and returns the reply. We hold no model credential, anywhere.

The model is called only where your settings send a turn to it. Every turn is read by the selector first, and a turn that names a tool, needs a question asked, or is declined never reaches the model. A turn nothing reads is asked to be said again once; then on_unread decides: refuse, model, ask (the person says whether the model may have it) or answer, from your documents (chapter 44). With no model, on_unread: refuse, no hands_off_to, replies from your phrasings and answers in your documents' own sentences, no model is ever called (chapter 45).

Runs and storage

runs: atomic, the default, undoes a failed run, last write first (chapter 34). storage: is where runs and waiting confirmations are kept, so that a confirmation can be answered after a restart, or on another worker: sqlite:///runs.db for one machine, postgres://... for several.

The commands

Generated from the stalwart command itself:

Command Takes What it does
stalwart verify <file>, --keep, --dependencies, --replay, --export-only, --no-register the code checks here, then the catalogue registered with its rulebook, and the agents' graph and rulebook hunted on our servers; exits 1 when the gate fails (chapter 46)
stalwart serve <file>, --host, --port, --agent, --operator-token, --dev-pair, --public-url, --sleeps, --follows, --console-origin the agents over HTTP, with the interface at / (chapter 35)
stalwart trace a run rendered from its spans, or from the run store alone: show, html or diff (chapter 36)
stalwart rollback a stopped run rolled back from its own checkpointer: what it changed undone, newest first, --thread naming the run
stalwart redteam <file>, --graph, --mcp, --writes, --hunter, --export-only, --keep attacker campaigns at what verify found, or open, every tool a stub (chapter 33)
stalwart gateway the MCP gateway, every call checked on its way to the tool (chapter 37)
stalwart mcp the guardrail's MCP server, for your AI assistant: what it checks, building one, and measuring it on your own calls (the guardrail)
stalwart doctor the key, the service, streaming through your network, the local runtime, the licence and the quota left; works with no key
stalwart check <certificate>, --cnf, --json, --print-source a certificate checked here, offline, by the reference checker; needs no key (chapter 7)
stalwart catalogue import a catalogue from an OpenAPI document or a TypeScript project, its code checked first (chapter 21)
stalwart rules compile a rule sheet compiled and linted, and saved with --save (chapter 41)
stalwart sources build ask the documents your agents answer from, indexed and asked here (chapter 44)
stalwart models fetch the bot's models, downloaded before it starts, with a progress bar; works with no key
stalwart licence activate request answer install release status, <file>, --output this machine's licence, offline activation included; works with no key (chapter 38)

The errors

Every error the package raises names what to change, in the words of agent.yaml, never in the terms of the libraries underneath:

Error When, and what to do
StalwartError every error of the package is one, so one except StalwartError catches them all
AgentFileError agent.yaml says something this release cannot read; the message names the file and the line
HunterUnavailable the check could not run, so nothing was checked; never a clean result
LicenceRequired no key, or no licence activated on this machine: set QLS_API_KEY and run stalwart licence activate
ModelKeyMissing the model's provider needs a key your environment does not hold; it names the variable, and nothing was sent
ModelUnavailable the model named cannot be used here; the message says what to install or change
NotVerified the agent has a way through its checks; findings names each, and nothing ran
RulebookNotHeld the agent declares a rulebook the tool check does not hold, and it could not be registered; nothing runs
RunTooLong a run took more steps than its limit: raise max_steps, or give the agent fewer tools to choose between
StateConflict two steps of a run wrote to the same part of its state at once; it names them
StorageError the store the runs are kept in could not be read or written; it names the store
ThumbRefused a thumb that would record a false case, refused, saying what to thumb instead; nothing was posted
ToolSaid a tool refused what it was called to do and said why, as an MCP server's error answer does; the run stops and the person is shown the tool's own words
ToolUnavailable an agent lists a tool with nothing to run it: declare its source, python, mcp or openapi

A file error names its line:

agent.yaml:7: agent 'desk' lists 'refund', which no tools source declares

If you already use LangGraph

Underneath, the agent runs as a LangGraph graph. desk.graph() returns it, compiled and verified, for a team that wants to extend it with nodes of its own; nothing in Part I needs it. Chapter 27 is the door for that team, written in LangGraph's own terms.

The limits

What stalwart-agents decides, and what stays yours:

  • It decides which tool a turn names, whether a call may run, waits for a person or is refused, what is undone when a run fails, and what a reply may say about what was done.
  • Your functions decide what they do. A tool's words are taken as you state them: a tool declared a read that writes is governed as a read. The code checks read your code for network and credential problems, and nothing else about what it does (chapter 46).
  • Your model decides what it says and which tool it asks for, where your settings let it. Every call it asks for is checked; its choices are not proved good (chapter 31).
  • A tool reachable outside the agent can be called around it. The checks govern the calls the agent makes; chapter 37 puts the same check in front of your tools for everything else.