The client

stalwart_agents.client is the client for the Stalwart API: choose the right tool for every turn of a conversation, check every tool call your agent makes, and verify guardrails, agent graphs and random forests.

pip install stalwart-agents

Python 3.10 to 3.14. Set QLS_API_KEY, and the client reads it. The client needs the key and an activated licence (stalwart licence activate, once on each machine) for everything but checking a certificate (the licence and the runtime).

Plans and tool checks read your users' turns and your agents' calls, so they are answered on your own host: by the licensed runtime the wheel carries (stalwart licence activate, once), or by the enterprise qls-router you run in your own environment (router_url=, or QLS_ROUTER_URL). Stalwart's hosted API answers neither. With neither set up, a plan or a check raises SelectorUnavailableError, saying which to set up.

Routing a conversation

Register your tools once, then read each turn of a conversation into a plan. No model is asked: the plan names the tool, or the question to ask the user, and the rule that decided.

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")
print(plan.outcome)                    # "run", "ask", "declined" or "unread"
for step in plan.steps:
    print(step.kind, step.selection.tool, step.selection.missing)

The session carries the conversation's context -- the question still open, the values typed, what a call returned -- from one turn to the next. It interprets none of it: the next context comes back with each plan, and is sent with the next turn.

plan = chat.read("show me the stock in depot HB-7")
if plan.outcome == "ask" and plan.chain:
    # The step can be completed by listing first: make those calls, and report them.
    results = [{"tool": c["tool"], "arguments": c["arguments"], "result": my_tools(c)} for c in plan.chain]
    plan = chat.answered(plan, results)
stored = chat.to_json()                # for a stateless backend; session_from_json() restores it

A plan has no truth value: if plan: raises, because "did it work" means four things here. A declined plan is never handed to a model, whatever on_unread says. A turn that needs a calculation no tool answers is run in the qls-sandbox container image on your own host, when a container engine and the image are present (client.compute); where they are not, the plan is declined, with a reason starting compute off:.

What is sent to Stalwart's servers

A solve and its inputs (uploads included), a guardrail generation prompt, a catalogue, a rule sheet and the vocabulary miner's model are scanned on this machine before anything is sent, for e-mail addresses, telephone numbers, payment card numbers, IBANs, UK National Insurance numbers, Brazilian CPF numbers and the identifiers you list as your own:

import re

from stalwart_agents.client import PersonalDataError

guarded = Client(own_identifiers=["Acme Holdings", re.compile(r"ACME-\d{6}")])
try:
    guarded.register_catalogue({"tools": [{"name": "get_order", "description": "Ask jo@acme.co.uk"}]})
except PersonalDataError as refused:
    print(refused.findings[0].kind, refused.findings[0].path)   # email document.tools[0].description

A match sends nothing. The error names each match's kind and where it is, never its value. An address at example.com, .example, .invalid or .test is a placeholder, not a person's.

Checking a tool call

check = chat.check_tool_call("send_payment", {"payee": "ACME", "amount": 120})
print(check.decision, check.reasons)   # "allow", "confirm" or "refuse"
chat.ran(check, outcome="ran")         # what became of the call, for the next check

Verifying a guardrail

result = client.verify_guardrail("policy.co", "execute_transfer_funds", "is_authenticated")
print(result.status)                   # "UNSAT", "SAT", "STALL", or None for a void verdict
if result.certificate:
    result.certificate.save("certificate.json")   # verified against its digest as it is written

A SAT verdict raises nothing: it is the answer to the question, with the repair in result.corrective_action and result.patched_policy.

Checking a certificate yourself

A certificate can be checked with no key, no network and none of our software.

  1. The standalone checker: one file, the standard library only, MIT licensed: https://api.stalwart.it/downloads/qls-check.py, with its digest at https://api.stalwart.it/downloads/qls-check.py.sha256. The format it checks is specified at https://api.stalwart.it/certificate-spec/.
  2. The same file, carried in this package, as a convenience:
from stalwart_agents.client import check_certificate

outcome = check_certificate(certificate_path)
print(outcome.outcome, outcome.reason)  # "verified", "not_self_contained" or "failed"

or stalwart check certificate.json. stalwart check --print-source writes the carried file out, to diff against the published one. A check establishes that a derivation is valid, never that a policy is safe.

Errors

Exception When What it carries
LicenceRequired (from stalwart_agents.errors) no API key, or no activated licence on this machine names stalwart licence activate and where to get a key
ConfigurationError a bad base URL, a runtime that is not there
SelectorUnavailableError a plan or a tool check with no usable runtime and no router_url
PersonalDataError personal data in a request bound for Stalwart's servers; nothing was sent .findings (kind and path)
AuthenticationError 401
NotEntitledError 403 .entitlement
RefusedError 409, a combination that cannot answer the question .slug, .explanation, .stage
QuotaExceededError 429 on a monthly meter; never retried .meter, .used, .limit, .resets_at
ConcurrencyLimitError, RateLimitError 429 that clears by itself; raised after the retries .current, .limit
InputTooLargeError 413 .limit_bytes
JobFailedError a job ended failed .cause, .stderr_tail
DigestMismatch a download that does not hash to its digest
StreamError a stream that cannot be resumed
ServiceError 5xx, after the retries .request_id

Retries

Reads, plans and tool checks are retried on a dropped connection, a timeout, a 5xx, and a 429 that clears by itself (honouring Retry-After). A solve is submitted under an idempotency key the client derives, so a retried submission is one job and a deliberate second one is two. A catalogue registration, a feedback record and a hunt attempt are retried under one key per call, so a retry records once and a second call records again. A cancellation's effects are never repeated.

What the client does not do

It checks the shape of what you send -- a config key the service does not have, a profile a mode refuses -- and nothing else (what the client does not validate). Whether a request can answer its question is the service's to say, once. It carries no selector code: a turn is read by the licensed local runtime the stalwart-agents wheel carries, or by your qls-router, never by this module.

Compatibility

stalwart-agents API
1.x /v1

Licence

© Stalwart Technologies Ltd. Proprietary; see LICENSE. The carried checker under stalwart_agents/client/_vendor/ is MIT, with its own LICENSE. Support: crm@stalwart.it.