35 · People in the loop¶
Some calls wait for a person: every write, before it runs; a write nobody knows the outcome of; an undo that
keeps failing. This chapter is what that person sees and answers, how an answer reaches the run wherever it
waits -- on another worker, from another channel -- and the interface that shows it all,
@stalwart-agents/agent-ui, which stalwart serve gives you before you write any of it.
Language support
The selector reads turns written in English and in Brazilian Portuguese (pt-BR). The language is chosen, never guessed from the turn: the console's Settings page (Language the selector reads), language on a plan request, or the settings.language of your vocabulary file; with none of them, English. A Portuguese turn may be typed with its accents or without them. European Portuguese is not a grammar the selector has, and a turn in any other language is unread.
A confirmation, from the person's side¶
The shop agent's charge from chapter 30, as it waits:
card: visa-0019
customer: c-208
order_id: 4411
status: unpaid
total: 40.00
charge_card | Run charge_card? | ['approve', 'skip', 'cancel_all']
{'name': 'amount', 'value': 40, 'origin': 'typed'}
{'name': 'order_id', 'value': '4411', 'origin': 'typed'}
amount: 40
charge_id: ch-4411
order_id: 4411
A confirmation shows:
- the call: the tool and its question --
Run charge_card?, or the wording your tool gives; - each argument, with where its value came from:
typed(the person typed it),reply(they answered a question),context(your application gave it, such as the signed-in customer),computed,result:<step>(an earlier step returned it), ormodel(the model wrote it). The interface says these in words: you typed it, the assistant wrote it; - the reasons it waits, as the check gave them:
write,argument_origin: amount,tainted_call: untrusted:read_inbox(chapter 32); - the irreversible warning, for a tool marked
irreversible(chapter 34); - the answers it takes.
The answers¶
Every pause a run makes, and the answers each takes, generated from the product:
| Kind | When a run pauses with it | Answers it takes |
|---|---|---|
confirm |
a call the check answered confirm |
approve, skip, cancel_all |
outcome_unknown |
a write that may or may not have taken effect, and is not safe to send again | happened, did_not_happen |
saga_stuck |
an undo that failed past its retries, with what still needs undoing | retry, done_by_hand, abandon |
ask |
a question the run needs answered to go on | answer |
delegate |
a turn nothing reads, under on_unread: ask: may the model have it? |
approve, decline |
approve runs the call with exactly the arguments shown, and nothing else. A confirmation approves only
the call shown: its token covers that tool with those arguments at that point of the run, so a call whose
arguments changed after it was shown -- by the model, or by anything else -- is checked again and asks
again (chapter 32). skip and cancel_all are in
chapter 34.
In Python, reply.waiting lists what waits, and desk.confirm(user, "approve") answers it.
Resuming on any worker¶
A run that waits is kept where storage: says -- sqlite:///runs.db for one machine, postgres://... for
several. Every pause has a stable id: the same call, paused for the same reason, has the same id on every
worker. So the answer can arrive anywhere: the worker that receives it reads the run from storage, takes the
answer to it, and carries on. Nothing is held in the process between a question and its answer.
Several pending at once¶
The agents agent.yaml describes take the calls of a turn in order, each to its end, so a run of theirs
waits on one thing at a time. Steps a team writes itself can run calls side by side, and then a run waits on
several at once. Each is answered by its own id -- desk.confirm(user, "approve", waiting=id), or the id in
POST /threads/{id}/resume -- and the others stay open. With no id, desk.confirm answers the first that
waits; GET /threads/{id}/interrupts lists them all.
A stale resume¶
An answer to a pause that is no longer open -- someone else answered it, and the run moved on -- is
refused as 409 interrupt_stale, and changes nothing. Two answers to the same pause that arrive together, on
one worker or two, are told apart by a claim kept in the run store: the first takes it and resumes the run;
the other is interrupt_stale. An answer that carries a token other than the one the person was shown is
interrupt_stale too. The interface reads what waits again when it gets one.
Approval from another channel¶
A confirmation can be answered from somewhere other than the chat: a queue, an e-mail, a chat tool your team
already uses. The application stalwart serve runs, make_app in stalwart_agents.serve, takes two
arguments for it:
on_interrupt=, told of each new pause as(thread, interrupt). The package'sWebhook(url, secret)is one: it posts the pause as JSON, signed in aStalwart-Signature: t=<time>,v1=<hmac>header, the HMAC-SHA256 of the time and the body under your secret. A receiver accepts a delivery only within five minutes of its own clock, so a captured one cannot be replayed later. A post that fails never fails the turn, which already ran: the answer lists what it could not post undernot_notified;secret=: where set, a resume must carry a validStalwart-Signaturetoo, so only the channel that holds the secret can answer.
stalwart serve itself takes neither; a team that wants approvals from another channel mounts make_app
in its own server.
If you already use LangGraph
Webhook lives with the resume server, stalwart_agents.langchain.server, which mounts on any ASGI
application over your own checkpointer; chapter 27
is its door.
What stalwart serve gives you¶
$ stalwart serve agent.yaml
serves your agents over HTTP, with the interface at /, before you write any interface yourself. Its
routes, generated from the package:
| Route | Does |
|---|---|
GET / |
the interface: chat, confirmations, refusals with their rule, rollbacks |
POST /threads/{id}/messages |
a turn: {"text": ...}; the answer holds the reply, what waits for a person, and any refusal or rollback as data |
GET /threads/{id}/interrupts |
what waits for a person in the thread, each by its own id |
POST /threads/{id}/resume |
a person's decision on one of them: {"interrupt_id", "decision", "token"}; the others stay open |
GET /threads/{id}/stream |
the thread's steps as server-sent events, resumable by Last-Event-ID |
POST /threads/{id}/rollback |
an operator undoes the thread's run, with Authorization: Bearer <operator token> |
POST /threads/{id}/feedback |
a person's thumb on a reply: {"message", "thumb"}, the thumb up or down, from the session that was shown the reply |
GET /healthz |
200 while the service answers |
GET /config.json |
what the interface at / is configured with, the agent's name and the base its routes are under |
Its options:
| Option | What the product then does |
|---|---|
--host |
the address to listen on; 127.0.0.1 where not given |
--port |
the port; 8700 where not given |
--agent |
the agent that answers first; the file's first where not given |
--operator-token |
what the rollback route must carry as Authorization: Bearer; QLS_OPERATOR_TOKEN where not given, and with neither the route answers 403 |
--dev-pair |
prints a one-time six-digit code the console pairs a browser with, for development on this machine |
--public-url |
the address people reach this host at, which the links in a card, the pushes and the passkeys name, as https://bot.example.com |
--sleeps |
this host is a laptop or a phone that sleeps, so it never takes a schedule set to always_on |
--follows |
follow the bot saved in the console, as Run it on my computer installs it: a newer version is applied and the bot starts again, one that takes something away waits for the owner, and a newer release is told of on the chat page |
--console-origin |
with --dev-pair, the origin of the console that pairs, as http://localhost:5173 for one run locally; https://api.stalwart.it where not given |
The operator's rollback undoes a thread's run from outside it. It takes the operator token as
Authorization: Bearer <token>, compared in constant time; with no token set, the route is off and answers
403, saying how to set one.
@stalwart-agents/agent-ui¶
The interface stalwart serve shows is a package of its own, for a team that puts the conversation in its
own pages:
$ npm install @stalwart-agents/agent-ui
Embedding it¶
Importing the package defines its elements; one of them is the whole conversation:
<stalwart-chat thread="u-17" base="/agent"></stalwart-chat>
base is where your stalwart serve (or your own mount of it) answers; thread is the conversation's id.
Every element, generated from the package's source:
| Element | Shows | Attributes | Properties | Events | CSS parts | Slots |
|---|---|---|---|---|---|---|
<stalwart-chat> |
the conversation, and whatever waits for a decision | base, staff, theme, thread, thumbs |
bot, eventSource, fetcher, isConnected |
— | thumbs, thumbs-notice, header, logo, name, greeting, help, can, can-group, about, asks-first, what-i-can-do, privacy, log, error-detail, image, files, typing, status, steps, correcting, composer, citations, sources |
header, footer |
<stalwart-confirm> |
one call that waits, its arguments with their origins, the reasons, the irreversible warning and its answers | theme |
interrupt, labels, single |
stalwart-decision |
confirm, wording, turn, arguments, reasons, irreversible, choices |
— |
<stalwart-decision> |
a refusal in one sentence, its rule on disclosure | contact, rule, sentence, staff, theme |
— | — | decision, contact, rule |
— |
<stalwart-rollback> |
what a run undid, and what it could not | theme |
report |
— | rollback |
— |
<stalwart-trace> |
a thread's steps | theme |
events |
— | trace |
— |
<stalwart-confirm> dispatches stalwart-decision with the pause and the answer chosen;
<stalwart-chat> sends it to the backend for you.
The backend it talks to¶
The interface talks to base and nothing else: the routes above -- a turn, what waits, a
decision, the step stream, a thumb. No telemetry, no font, no script from any other host. Each request names
the page's session, once the server has handed one out, so a thumb is taken only from the session that was
shown the reply.
Theming it¶
Every colour and size is a CSS custom property, with a light and a dark default; theme="light" or
theme="dark" fixes one, and with neither the person's own setting chooses:
| Custom property | Light | Dark |
|---|---|---|
--stalwart-bg |
#ffffff |
#121212 |
--stalwart-fg |
#1a1a1a |
#f1f1f1 |
--stalwart-muted |
#595959 |
#bdbdbd |
--stalwart-line |
#d0d0d0 |
#3c3c3c |
--stalwart-accent |
#0b57d0 |
#8ab4f8 |
--stalwart-accent-fg |
#ffffff |
#0b0b0b |
--stalwart-warn |
#8a4b00 |
#ffb74d |
--stalwart-bad |
#b3261e |
#f28b82 |
--stalwart-radius |
8px |
8px |
--stalwart-gap |
12px |
12px |
--stalwart-font |
system-ui, sans-serif |
system-ui, sans-serif |
Each region is a ::part (listed above), and <stalwart-chat> has header and footer slots for your
own content. For the shop's colours, for instance:
stalwart-chat { --stalwart-accent: #2e7d32; --stalwart-radius: 4px; --stalwart-font: Georgia, serif; }.
Replacing its words¶
Every word the interface shows is a string with a key, and setStrings({ approve: "Aprovar", skip: "Pular
este passo" }) replaces any of them; a {name} in a string is filled where it is shown. The strings as
shipped:
| Key | Shipped text |
|---|---|
conversation |
Conversation |
placeholder |
Write a message |
send |
Send |
sending |
Sending… |
writing |
Writing a reply… |
waiting |
Waiting for your decision |
confirmTitle |
{tool}? |
argumentsTitle |
What it will use |
origin_typed |
you typed it |
origin_reply |
you answered it |
origin_context |
from your account |
origin_computed |
calculated |
origin_model |
the assistant wrote it |
origin_result |
from an earlier step |
origin_agent |
from another assistant |
reasonsTitle |
Why this needs you |
irreversible |
Nothing can undo this step once it runs. |
approve |
Approve |
skip |
Skip this step |
cancelAll |
Cancel everything |
cancel |
Cancel |
refused |
Not done |
rule |
Rule |
showRule |
Which rule? |
rollbackTitle |
Undoing what this run did |
undone |
Undone |
notUndoable |
Could not be undone |
stuck |
Undoing “{tool}” did not work. What should happen? |
retry |
Try again |
doneByHand |
I undid it myself |
abandon |
Leave it |
unknownTitle |
Did “{tool}” go through? |
happened |
Yes, it happened |
didNotHappen |
No, it did not |
delegateTitle |
I couldn't work that out. Shall I pass it to the assistant? |
delegateApprove |
Yes, pass it on |
decline |
No |
reconnecting |
Reconnecting… |
traceTitle |
What happened |
step |
Step {step} |
error |
Sorry, that did not work. Please try again in a moment. |
details |
Details |
thumbUp |
Helpful |
thumbDown |
Not helpful |
thumbsNotice |
A rated message is kept to improve the assistant, and nothing else of the conversation. |
sources |
Sources |
fromOutside |
from outside: {server} |
canTitle |
What I can do |
tryIt |
Try it |
asksFirst |
asks you first |
greeting |
Hello! I'm {name}. {purpose} |
contact |
To ask {name} directly: {contact} |
privacyLine |
This chat is run by {name}. |
privacyLink |
Privacy notice |
helpLink |
How to use me, with examples |
correcting |
Changed your mind? Write it and I'll start again from what you say. |
staffOnly |
This bot answers its owner and their staff only. |
modelKeyTitle |
Your bot needs your AI key |
modelKeyHelp |
When your bot doesn't understand something, it uses an AI model with your own key ({variable}). Until you add it, it says it can't help. The key stays on this computer. |
modelKeyLabel |
Your key |
modelKeySave |
Save the key |
modelKeySaved |
Saved. Your bot is starting again with it. |
modelKeySavedLater |
Saved. Your bot uses it the next time it starts. |
modelKeyFailed |
That key was not saved. Paste it as your AI provider gave it. |
filesTitle |
Your files |
filesWhere |
What your bot saved, newest first. They are in {folder} on this computer. |
filesNone |
Your bot has saved nothing yet. |
filesOpen |
Open |
filesDownload |
Download |
phoneTitle |
Use it on your phone |
phoneStart |
Show the code |
phoneScan |
On your phone, on the same Wi-Fi as this computer, open the camera and scan this code. The code works once, for ten minutes. |
phoneWarning |
Your phone will say the connection is not private: your bot made its own certificate, here. Check that the certificate's fingerprint starts with {start}, then go on. |
phoneNoNetwork |
This computer is on no network your phone can reach. Join your Wi-Fi, then try again. |
The limits¶
- A confirmation approves only the call shown, with its arguments; a changed argument asks again. It proves that a person approved, not that they read it.
- An answer reaches a run that waits; it never changes one that moved on (
interrupt_stale). - A webhook's receiver is yours: the signature proves the delivery came from your server within five minutes, and nothing about who answered it on the other side.
- The interface shows what the run reports -- a refusal's rule, a rollback's undos -- never a reading of the reply's words.