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 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), 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

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.