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 understands turns written in English. Portuguese is its next grammar, and has not shipped yet; until it does, a turn written in Portuguese is not read as Portuguese.
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 |
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, theme, thread, thumbs |
eventSource, fetcher |
— | thumbs, thumbs-notice, log, status, steps, 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 |
stalwart-decision |
confirm, turn, arguments, reasons, irreversible, choices |
— |
<stalwart-decision> |
a refusal in one sentence, its rule on disclosure | rule, sentence, theme |
— | — | decision, 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… |
waiting |
Waiting for your decision |
confirmTitle |
Confirm {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 |
refused |
Not done |
rule |
Rule |
showRule |
Which rule? |
rollbackTitle |
Undoing what this run did |
undone |
Undone |
notUndoable |
Could not be undone |
stuck |
Undoing {tool} failed. What should happen? |
retry |
Try again |
doneByHand |
I undid it myself |
abandon |
Leave it |
unknownTitle |
Did {tool} take effect? |
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 |
Something went wrong: {message} |
thumbUp |
Helpful |
thumbDown |
Not helpful |
thumbsNotice |
A rated message is kept to improve the assistant, and nothing else of the conversation. |
sources |
Sources |
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.