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.
- The standalone checker: one file, the standard library only, MIT licensed:
https://api.stalwart.it/downloads/qls-check.py, with its digest athttps://api.stalwart.it/downloads/qls-check.py.sha256. The format it checks is specified athttps://api.stalwart.it/certificate-spec/. - 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.