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 understands turns written in English. Portuguese is its next grammar, and has not shipped yet; until it does, a turn written in Portuguese is not read as Portuguese.

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

Registered as catalogue version 1, with the rulebook policy.co: the tool check enforces the rules verified here.
Lints at registration (nothing fails on a lint):
  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_without_compensation (warning) on refund_card: a write neither compensated nor marked irreversible
  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_not_retry_safe (warning) on send_receipt: a write with no idempotency, so an ambiguous failure goes to a person
  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 4b48b0081360).

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: Declare a compensation for 'refund_card' in the catalogue, or mark it irreversible.

QG105 Review on refund_card (policy.co:19)
  the write 'refund_card' has no compensation, is not irreversible, and runs on a path with another write
  OWASP LLM03:2026, ASI08:2026
  Fix: Declare a compensation for 'refund_card' in the catalogue, 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

stalwart verify reads your code for network and credential problems on your machine, registers the catalogue with 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).

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",
             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

Every word the file takes, 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, or name: a tool's words
rulebook the Colang policy every tool call is checked against and the graph is proved against
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)
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 the most steps this agent's loop may take
agents.<name>.system what the model is told before the conversation
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
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).

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.
  • 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 the tool runs at most this many times in a session
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
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

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

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

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 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 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 redteam <file>, --findings, --open, --clean, --rounds, --attacker 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 doctor the key, the service, streaming through your network, the runtime or your router, 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 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
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.