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