Skip to content

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), or model (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's Webhook(url, secret) is one: it posts the pause as JSON, signed in a Stalwart-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 under not_notified;
  • secret=: where set, a resume must carry a valid Stalwart-Signature too, 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.