Build an agent
This page takes you from nothing to an agent of your own, answering over HTTP: an account, a key,
a licence, two tools, one agent.yaml, a turn, a confirmation, and stalwart serve.
Open an account
Sign up at stalwart.it, with your email address or with Google. A free account opens with its first API key, in the console at api.stalwart.it/console. The key is shown once, when it is minted: copy it then. What the free plan includes, and what the paid and enterprise plans add, is on stalwart.it.
Install the package and activate the licence
$ pip install "stalwart-agents[anthropic]" # or openai, google, mistral, ollama; or none, for no model at all
$ export QLS_API_KEY=qls_live_...
$ stalwart licence activate
stalwart licence activate is run once on each machine that runs your agents. Nothing in the
package works without the key and an activated licence; stalwart doctor says what is missing
and how to put it right. The licence and the runtime
says what a licence is and what works without one.
Write the tools
A tool is a plain Python function. Its type hints are its schema, its docstring's first line
is what it does, and @tool(...) gives it the words the checks read:
# shop/tools.py
from decimal import Decimal
from stalwart_agents import tool
def get_order(order_id: str) -> dict:
"""Show an order: its items, its total and its status."""
return {"order_id": order_id, "total": "42.00", "status": "paid"}
@tool(writes=True, undo="refund_card")
def charge_card(order_id: str, amount: Decimal) -> dict:
"""Charge the card on file for an order."""
return {"charged": str(amount)}
@tool(writes=True)
def refund_card(order_id: str, amount: Decimal) -> dict:
"""Refund an amount to the card on file for an order."""
return {"refunded": str(amount)}
writes=True says the tool changes something, so its call is checked against your rules
before it runs. undo="refund_card" says how the write is undone if the run fails after it.
Declare the agent
# agent.yaml
agents:
desk:
model: "anthropic:claude-sonnet-5-5" # your provider and key, from your environment
tools: [get_order, charge_card, refund_card]
on_unread: model # a turn no tool covers goes to the model
tools:
- python: shop.tools # every public function of the module
runs: atomic # a failed run is undone, last write first
storage: sqlite:///runs.db # where runs and waiting confirmations are kept
The model is called only where this file sends a turn to it: here, a turn no tool covers.
Leave out model, or set on_unread: refuse, and no model is ever called
(worked examples).
Check it before it goes live
$ stalwart verify agent.yaml
stalwart verify registers the catalogue, verifies every route through the agents' graph and
hunts the rulebook, and exits 1 when the gate fails, so it runs in CI as it runs here. The
catalogue it registers is the one the rule sheet below is linted against, and it carries the
rulebook the graph was proved against, for every tool check to enforce
(guarded and proved).
State the rules
The rules over your tools are a sheet a person can read aloud. Here, every charge waits for a person, and nothing is refunded above 200 without a supervisor's approval:
# rules.yaml
rules:
- action: charge_card
requires_confirmation: true
- action: refund_card
requires: [supervisor_approved]
when: { amount_over: 200 }
$ stalwart rules compile rules.yaml --save
--save registers the sheet on your newest catalogue, and every tool check enforces it from
then on. A rule the compiler cannot state exactly is refused with its line, never approximated.
Talk to it
from stalwart_agents import Agent
desk = Agent.from_file("agent.yaml") # the graph is verified once, before the first turn
reply = desk.chat("show me order 4411", user="u-17")
print(reply.text) # get_order was chosen from the words; no model was asked
Confirm a write
A write the rules say needs a person waits for one. The reply says what waits, in the words a person approves:
reply = desk.chat("charge 42.00 to order 4411", user="u-17")
for waiting in reply.waiting:
print(waiting.tool, waiting.wording, waiting.decisions) # charge_card ... ['approve', 'skip', 'cancel_all']
reply = desk.confirm("u-17", "approve") # the call runs now, with the exact arguments shown
skip declines this call and goes on; cancel_all stops the run and undoes what it wrote.
Serve it
$ export QLS_OPERATOR_TOKEN=$(python -c "import secrets; print(secrets.token_urlsafe(32))")
$ stalwart serve agent.yaml
Open http://127.0.0.1:8700/ for the chat interface, with its confirmations, refusals and
rollbacks. The same routes answer any client:
$ curl -X POST http://127.0.0.1:8700/threads/t-17/messages -d '{"text": "show me order 4411"}'
$ curl http://127.0.0.1:8700/threads/t-17/interrupts # what waits for a person
$ curl -X POST http://127.0.0.1:8700/threads/t-17/resume -d '{"interrupt_id": "...", "decision": "approve"}'
An operator undoes a thread's run with POST /threads/{id}/rollback, which needs the operator
token as Authorization: Bearer; with no token set (--operator-token or QLS_OPERATOR_TOKEN),
the route answers 403 and says how to set one. With storage: postgres://..., several workers
answer the same threads, and a confirmation is resumed on whichever worker receives it.
Next
- Worked examples: the model called only where you allow it, or never; controlled rollbacks; a rule sheet; answers from your documents; a team of agents verified before it runs.
- The overview: every part of
agent.yamland every command. - The Python API reference:
Agent,tooland the rest.