Worked examples

Each section shows one thing the product does, as you would do it. The agent examples assume the agent.yaml and tools of Build an agent; the verification examples assume a Client() with QLS_API_KEY set. Every example needs your key and an activated licence (the licence and the runtime).

A turn read with no model call

Your agent reads each turn against the words each tool declares -- its name, its description, its arguments -- and picks the tool itself. No model is asked to choose, the same words get the same tool, and every step names the rule that produced it:

from stalwart_agents.client import Client

client = Client()
version = client.register_catalogue(mcp_tools_list, agent="desk")
chat = client.session("desk", catalogue=version)
plan = chat.read("show me the stock in depot HB-7")
for step in plan.steps:
    print(step.kind, step.selection.tool, step.decided_by)   # tool ListStock head-parts

An Agent does the same on every turn before anything else runs. The plan is made on your own machine, by the licensed runtime, or by the qls-router you run in your environment.

Calculations no tool covers, handed to the model

A turn no tool covers -- a calculation, say -- goes to your model only where agent.yaml says so. Here it does:

agents:
  desk:
    model: "anthropic:claude-sonnet-5-5"   # your provider and key
    tools: [get_order, refund_card]
    on_unread: model                       # a turn no tool covers, a calculation say, goes to the model
from stalwart_agents import Agent

desk = Agent.from_file("agent.yaml", name="desk")
reply = desk.chat("what is 17.5% VAT on 240 pounds?", user="u-17")
print(reply.text)            # the model's answer
print(reply.rated)           # the turn's record: the verdict `unread`, so the model answered it

on_unread: ask asks the person first, and the model has the turn only once they say yes (desk.confirm("u-17", "approve"), or "decline").

Where a plan can run the calculation itself, it does, on your machine: compute steps run in the qls-sandbox container image on your own host, when a container engine and the image are present. Every plan includes some -- 100 runs and 300 seconds a month on the free plan, 10,000 runs and 30,000 seconds on the paid plan, no limit on enterprise. Where the image is not there, a plan that needs a calculation says compute off: in its reason, and on_unread decides what happens to the turn.

No model call at all

Turn the model off and it is never called, for anything:

agents:
  desk:
    tools: [get_order, refund_card]
    on_unread: refuse                      # a turn no tool covers is refused, by name
replies:
  phrasings: phrasings.yaml
  composer: template                       # replies are your phrasings; no model writes them
answers:
  index: sources.json
  composer: extract                        # answers are your documents' own sentences

With no model, on_unread: refuse, no hands_off_to, replies from your phrasings and answers in your documents' own sentences, there is nothing left for a model to do. A turn no tool covers is refused with the reason, and the person is asked to say it another way once first.

Every tool call checked against your rules

Every call is checked before it runs, whoever chose it -- the reader or the model -- against the catalogue, the argument schema, the session's history and your rules. The answer is one of three: allow, confirm (a person approves the exact arguments shown) or refuse, and no model or network is involved in it.

# rules.yaml
rules:
  - action: issue_refund
    requires: [supervisor_approved]
    when: { amount_over: 200 }
  - action: close_account
    requires_confirmation: true
  - action: delete_customer
    never: true
  - action: send_statement
    after: [verify_identity]
$ stalwart rules compile rules.yaml --save
check = chat.check_tool_call("issue_refund", {"order_id": "4411", "amount": 450})
print(check.decision, check.reasons)   # "refuse", and the rule that decided

A refused call runs nothing, and the agent's reply says why. Your existing Colang 1.0 or 2.x policy can be the rulebook instead (rulebook: ./policy.co).

One catalogue from the tools you have

Your tools can come from Python functions, MCP servers, OpenAPI documents, LangChain tools and a TypeScript project, into one versioned catalogue:

$ stalwart catalogue import openapi.yaml
$ stalwart catalogue import ./tools-ts            # exported functions tagged @read or @write

Each registration is a version, linted before it routes: a write with no undo, two tools the reader cannot tell apart, a name the reader takes for grammar. The tools' own code -- a TypeScript project's sources on import, and your Python, JavaScript, TypeScript and Go under stalwart verify -- is first put through the code checks for network-security and credential problems, on your machine: a finding fails, nothing can suppress it, and only the counts are sent to Stalwart. The version before can always be restored (client.restore_catalogue(version)).

A team of agents verified before it runs

Agents hand off to each other with hands_off_to. Before the first turn, and in CI with stalwart verify, every route through the team is searched for a way to reach a risky action without its safeguard:

agents:
  desk:
    tools: [get_order, read_inbox]
    hands_off_to: [billing]
  billing:
    privilege: high
    tools: [issue_refund]
$ stalwart verify agent.yaml --keep findings/
$ stalwart verify agent.yaml --replay      # each finding's witness replayed on the agents, every tool a stub

A privileged tool reachable after untrusted input -- read_inbox here, then a hand-off to billing -- is a finding, with the path that reaches it and the line of agent.yaml it comes from. A dangerous path stops the agent: Agent(...) raises NotVerified rather than run it.

Validating a LangGraph graph

A graph you built with LangGraph is checked the same way. Export the compiled graph with the tool catalogue it binds, and send both to the graph mode:

import json
from stalwart_agents.client import Client
from stalwart_agents.langchain.graph import export_graph

app = builder.compile()                    # your LangGraph StateGraph
with open("graph.json", "w") as f:
    json.dump(export_graph(app, catalogue, model_nodes=["agent"]), f)
result = Client().verify_graph("graph.json", catalogue="catalogue.json")
for finding in result.findings:
    print(finding.severity, finding.rule, finding.location)

Each tool the graph can reach is held to its check, its confirmation, its order rules and what an undoable write needs, over the session depth you set (session_steps=). A finding comes with the exact path, in SARIF too, and replay_witness replays its witness on your graph with every tool stubbed. Nodes that are not Stalwart's own are read as opaque, and a clean result holds up to the depth checked.

Controlled rollbacks

Declare how each write is undone. When a run fails, its writes are undone last first, each undo checked against your rules like any call; a run waiting at a confirmation is cancelled there:

from decimal import Decimal
from stalwart_agents import Agent, tool

@tool(writes=True, undo="refund_card")     # how this write is undone
def charge_card(order_id: str, amount: Decimal) -> dict: ...

@tool(writes=True)
def refund_card(order_id: str, amount: Decimal) -> dict: ...

@tool(writes=True)
def provision_account(order_id: str) -> dict: ...

desk = Agent("desk", tools=[charge_card, refund_card, provision_account], rulebook="policy.co")
reply = desk.chat("charge order 4411 and open the account", user="u-17")
# provision_account fails -> the charge is refunded, last write first
runs: atomic                               # the default; `stop` leaves a failed run as it is

Under stalwart serve, an operator rolls a thread's run back the same way. The route needs the operator token you started the server with (--operator-token, or QLS_OPERATOR_TOKEN):

$ curl -X POST -H "Authorization: Bearer $QLS_OPERATOR_TOKEN" http://127.0.0.1:8700/threads/t-17/rollback

With no token set, the route answers 403 and says how to set one, so nobody who merely reaches the server can undo a run. In LangGraph, stalwart_rollback_node does the same, and ResumeServer(operator_token=...) guards its route the same way.

Receipts behind Kong

Point the agent's MCP source at your Kong route; the agent's catalogue is that server's tools/list. With receipts: true, each call carries the agent's own check -- the session's history, ending on the check that allowed it -- and Kong's route admits it by asking your qls-router's POST /v1/tool-receipts, so a write the person approved in the agent is not asked about twice:

tools:
  - mcp: https://kong.internal/mcp         # your MCP servers, behind Kong
    receipts: true                         # each call carries the agent's own check

A receipt admits one call, once: presented again for other arguments, another tool or a call already reported, it is a 403. A call without one is checked in full at your router's POST /v1/tool-checks. The Kong recipe has the route, the plugin settings and the network policy that make Kong the only way to the tools.

Replies true to what was done

replies:
  rules: reply-rules.yaml        # personal data by class, phrases never to say, allowed offers
  phrasings: phrasings.yaml      # how each write's outcome is told: done, failed, refused, ...
  composer: model                # or template: the reply is the phrasings, and no model writes it

Each sentence that speaks of a write is held to that write's outcome: done only if it ran, a failed or refused one never told as done, and every amount, date or identifier in it to a value the call was sent or returned. A sentence that misstates is replaced by the outcome's phrasing. Card numbers, IBANs, national identifiers, email addresses and telephone numbers the rules withhold are masked or the reply held back, banned phrases are removed and required disclaimers added. It runs in the local runtime, and only counts leave the machine.

Answers from your documents

$ stalwart sources build docs/*.md --out sources.json     # the index, made here, from local files
$ stalwart sources ask --index sources.json "how long does a refund take?"
answers:
  index: sources.json
  composer: extract        # the documents' own sentences; compose: your model writes from them
agents:
  desk:
    on_unread: answer

The documents and their index never leave your host. Each sentence of an answer cites its passage, and a composed sentence that states a number, date, amount, name or negation its passages do not is removed. compose needs a plan that includes composed answers.

Learning your users' words

reply = desk.chat("what is in order 4411?", user="u-17")
desk.thumbs(reply, up=False)        # one record: the turn's words, the tool, the reader's verdict and rule

Thumbs up and down are feedback records the hosted miner learns your users' words from. A new vocabulary is published only when it makes nothing worse on five checks, every version can be rolled back (client.revert_vocabulary()), and your feedback is used for your tenant alone, never to train models.

A certificate for every rule

Ask for a row per rule of your policy, give each row you keep an id, an expect and a gate, and run the table: under the patch profile, every row gets a certificate of its own.

import json
from pathlib import Path

from stalwart_agents.client import Client

client = Client()
proposed = client.enumerate_properties("policy.co")          # step 1: one candidate row per rule
rows = [{**row, "id": f"RULE-{n:02}", "expect": "UNSAT", "gate": "hard"}
        for n, row in enumerate(proposed.records, 1)]
Path("rules.json").write_text(json.dumps({"policy": "policy.co", "properties": rows}))

result = client.verify_batch("rules.json", "policy.co", profile="patch")   # step 2: run the table
for record in result.records:
    print(record["id"], record["status"], record["certificate_artifact_id"])
print(result.all_passed, result.hard_failures)

The profiles: fast, a verdict only, in about a quarter of a second, with no certificate and no repair; patch, a repair with a certificate you can replay; and proof. A certificate is checked with no key, no network and none of our software (checking a certificate yourself).

Every draft verified

Describe a rule in plain language and name your own model. Each draft is compiled and checked against the action and context you name, and a draft that fails is sent back to your model with the solver's repair, for up to three rounds where your plan allows:

result = client.generate_and_verify(
    "Funds may move only for a signed-in user whose account is not frozen.",
    "transfer_funds", "is_authenticated",
    provider="anthropic", model="claude-sonnet-5-5", api_key_env="ANTHROPIC_API_KEY", rounds=3,
)
print(result.status)                     # the verdict on the draft that ships
print(result.shipping_candidate)         # the draft, verified

Your model's key travels with this one request: the SDK neither stores nor logs it, and the service keeps neither the key, the prompt nor the model's raw answer.

Hunting a policy for bugs

result = client.hunt("policy.co", gate=True)
for finding in result.findings:
    print(finding.severity, finding.rule, finding.reproduction)
    print(finding.fix)                    # a corrective guard, checked by scanning again

Every finding has a severity, a reproduction -- the conversation that triggers it, replayed -- and a fix verified by scanning again. gate=True runs the quality gate, which can block a release, and the findings come as SARIF too. Scans grouped into projects, each compared with the last, and triage of each finding are on the console and the API (projects and scans).

Checking a random forest

result = client.verify_forest("model.onnx", "properties.json")
for record in result.records:
    print(record)                        # each property: holds, a counterexample re-checked on the model, or undecided
counter = client.enumerate_counterexamples("model.onnx", "search.json", anchors="records.json")

A forest is read as ONNX-ML or Treelite JSON, and a scikit-learn model through an ONNX export. Each violation comes with a counterexample checked again on the model itself; the search around real records is exhaustive, not sampled. A wide fairness or monotonicity region may stall: a stall concludes nothing, and a narrower region gets an answer.