Human in the loop
The points where an agent waits for a person, and who that person is.
It is the same file the platform's own tests are written against — LGFlow/docs/guides/human-in-the-loop.md — rendered here rather than copied, so it cannot fall behind the software it describes. It is written for somebody working on the platform and uses the internal project names: LGFlow is Symphonix Core, LGStudio is Symphonix Studio, and LGMonitor or LGStudioMonitor is the Symphonix Control Panel. Names that begin LGFlow and appear in code — LGFlowValidationError and the rest — are exported identifiers and are spelled exactly as the software spells them.
Approvals, forms, questions mid-run, and documents somebody hands over.
Say why, not just what (0.28)#
An approval card says what to approve. Until 0.28 it said nothing about how the agent got there, so whoever had to sign was reading a conclusion with the working thrown away - and an approver who does not know enough has two buttons and both are wrong. What they do is decline, which is the cheaper mistake and is paid by the customer.
"approval": { "ask": "Pay this refund?", "show": ["amount"], "why": "case_notes" }why names one channel holding the account. Write it the way the run
actually proceeds - a list channel with "reducer": "append", one line per
step - and it reads as what it is: the working, in order.
"case_notes": { "type": "list", "description": "One line per check, as it is made.",
"reducer": "append", "default": [] }Three things to know about it:
- It is the agent's account, and every screen that shows it says so. Write the lines as statements of what was done, not as argument.
- It is read when the run stops, so each run carries its own.
- Nothing is drawn when the channel is empty. A heading over blank space reads as there was no reasoning.
And show them what it rests on (0.29)#
The account is the story. exhibits is what the story is about - the
documents themselves, named as file channels:
"approval": { "ask": "Pay this refund?", "show": ["amount"],
"why": "case_notes", "exhibits": ["invoice", "bank_statement"] }"invoice": { "type": "file", "description": "The invoice the customer sent." }Three kinds of thing reach the card now, and they are drawn apart because they
answer different questions: show is what you are approving, why is
why you are being asked, exhibits are the evidence. Somebody
deciding quickly has to be able to open a document first and read the account
second — a card that can only be read top to bottom is a card that is only
ever agreed with.
Things to know:
- Every name must be a
filechannel. Anything else is refused at compile: a row saying there is a document where there is none is worse on an approval card than anywhere else. - The bytes do not travel. What goes to whoever decides is a name, a type, a size and a SHA-256. The bytes stay in this agent's own folder until an approver opens one, and then they come back over the link the agent already has. A board stores none of them.
- The fingerprint is inside the digest, with the fields and the account. Show one document and act on another and the decision no longer matches.
- A stopped agent is a document that will not open - and a long-waiting approval is exactly when an agent gets stopped. The card still says what the document is, and says why it cannot be fetched. That is the price of not copying customers' files into a database; it was chosen rather than discovered.
- A document the agent cannot read is still listed, marked unreadable, and still in the digest. Evidence that went missing is evidence; a card quietly showing one fewer document loses it twice.
Asking a person first#
examples/approval-desk — the v0.5 example. A refund desk that knows which
refunds are not its own to approve.
npm run desk # in the browser
npm run example:approval -- "refund ALFKI 240 for a damaged crate"
npm run example:approval -- --no "refund ALFKI 240 for a damaged crate"draft -+- auto ---> issueSmall ----------+
| |
|- ask ----> issueLarge --------> report -> END
| : |
| : refused |
+- reject ---> decline -----------+Under 100 the refund goes straight out. Above it, the run stops, saves itself, and waits for somebody:
{
"id": "issueLarge",
"handler": "issueRefund",
"approval": {
"ask": "Approve this refund?",
"show": ["customer", "amount", "reason"],
"onReject": "decline"
}
}Three things this example is built to make visible:
issueSmallandissueLargeare the same function. One function, two nodes — the v0.1 trick from the DSL reference. The money is paid identically either way; the only difference is that one of those nodes will not run until somebody says yes.- A refusal is not a rollback.
issueRefundnever runs, so it never writes its note either. The audit trail of a declined refund has no payment in it to explain away. The tests watch an injected payment system to prove it. shownames three channels. The reviewer sees the customer, the amount and the reason. They do not seenotes, because an audit trail is not what anybody needs in order to answer a question about a refund.
The same flow runs three ways without changing: the CLI asks at the terminal, the browser page offers two buttons, and the tests answer from a script. What differs from the word tutor's injected learner is that this wait is a checkpoint, and from 0.26.6 a checkpoint that outlives the process: the question can be answered tomorrow, by somebody else, on an agent that has been redeployed twice since it was asked.
Asking for what is missing, and reading documents — v0.10#
Opening with a form. An agent that needs particular details before it can
begin - a customer id, a date range, several fields at once - declares
"inputs": [] and makes a form node its start. The chat then opens with the
fields themselves: no message to type, no Start button, and every value checked
before the first node runs. One free-text question is still an input; a form is
for what a sentence cannot carry.
Two things a chat agent keeps needing, now part of the DSL: a form when a tool is short of something only the person knows, and a document read once and talked about for the rest of the conversation. Neither needs a handler.
A tool says what it takes#
{
"name": "addNewCustomer",
"description": "Adds one customer to Northwind.",
"inputs": {
"customerId": { "type": "string", "required": true, "maxLength": 5, "label": "Customer id" },
"companyName": { "type": "string", "required": true, "maxLength": 40 },
"city": { "type": "string", "maxLength": 15 },
"country": { "type": "string", "options": ["Israel", "France", "Germany"] }
}
}Before a call is made its arguments are checked against inputs. When a
required one is missing, or one given cannot be taken (too long, not a number,
not one of the options), the run stops and the chat shows a form: the fields
the tool takes, what is already known filled in, what is wrong marked beside
the field. Submitting resumes the run; a value that still does not pass brings
the form back with what was typed still in it. Nothing reaches the tool until
every field passes - the database never sees Acme01 for a five-character id.
Field types: string, text, number, boolean, date, list, object,
any, file. Each may carry required, label, description, options,
default, accept (files) and maxLength (text). The server checks and types
every submission - the page's own checks are a convenience, not the guard.
A list of like entries - the lines of an order - declares its row with of, and
the form shows it as rows with + Add, never as JSON to type:
"items": { "type": "list", "required": true, "label": "Products",
"of": { "productId": { "type": "number", "required": true },
"quantity": { "type": "number", "required": true },
"discount": { "type": "number" } } }Row fields are plain (string, text, number, boolean, date). A row left
empty is dropped; a row half filled in is refused by number - *row 2: Quantity is
required*. A tool may declare only some of its arguments: the rest pass through
untouched, so a form shows what a person should see and nothing else.
Cancel on the form ends the step as an error, so onError decides what
happens next.
In a tools node every queued call is completed first and only then are any
made: a form stops the run, a resumed run replays the node from the top, and a
call already made would otherwise be made twice.
A form of your own#
{
"id": "details",
"type": "form",
"title": "Where should we ship it?",
"fields": {
"address": { "type": "text", "required": true },
"express": { "type": "boolean", "default": false }
},
"output": "shipping"
}Asks, waits, and writes the answers as one object to output.
Fields the flow does not know yet — v0.17#
What is worth asking for is not always known when the flow is written. An MCP
server says what each of its tools takes in that tool's own inputSchema, read
at run time — so the form has to be built from a channel, not from the file:
{ "id": "collect", "type": "form", "title": "Add a customer",
"fields": "{{tool_schema}}", "output": "given" }One template naming one channel. A JSON Schema there is read as fields:
required stays required, enum becomes a choice, maxLength is checked,
array becomes rows with + Add. Write the fields out in full whenever they
are known — this is for the ones that cannot be.
Asking for what is missing — v0.17#
An llm node that cannot get on without something only the person can tell it
says so with "asks": true, and stops writing paragraphs that ask for things:
{ "id": "decide", "type": "llm", "asks": true,
"prompt": "Do what {{request}} asks.", "output": "next_step" }LGFlow tells the model it may reply with { "needs": { …fields… }, "why": "…" }
instead of its answer, shows those fields as a checked form, and asks the model
again with what was typed — so the answer it finally writes was decided knowing
all of it. Where the missing thing has a schema of its own, the model copies its
fields, which is what turns a tool's own inputs into a form nobody wrote out.
Put it on the node that decides, not on one that only reports. A reply that is not a request for fields is the answer, exactly as before, so switching this on cannot turn a working node into a broken one. It asks at most three times in one visit.
To answer a cancel in your own words, and end the turn there:
{ "id": "decide", "type": "llm", "asks": true,
"onCancel": "Action cancelled - nothing was read or changed.",
"prompt": "Do what {{request}} asks.", "output": "next_step" }Those words go straight into what the flow shows and the turn ends there: no further node runs, so a cancel costs no model call at all. Closing the form neither fails the node nor goes back to the model: the node writes nothing, the channel keeps what it held, and the flow’s own answering node tells the person. A form the flow declared still fails on cancel, because the flow said it needed those details. Resuming runs a node again from the top, so what the model already said is kept for the replay - across requests too, since a served agent compiles the flow again for each one. One form costs two model calls, not three, and a cancelled turn costs one. A model that changes its mind on the second telling cannot throw away what the person just typed.
Approvals that can be corrected#
"approval": {
"ask": "Add these customers to the Northwind database?",
"show": ["customer_calls"],
"edit": ["customer_calls"],
"onReject": "write_answer"
}edit names channels the person may change on the approval card before saying
yes. The answer is { approved: true, values }; the corrected values replace
the channels, are checked like any form, and only then does the node run. On a
tools node the missing-input forms come first, so what is approved is the
finished call.
Forms and approvals both wait in a checkpointer - a flow that can ask is refused without one.
Who answers it — 0.26.7#
Until now, whoever had the chat open. That is the wrong question for an organisation: the person who may sign off a credit memo is not necessarily the person with the window open, and may be three people.
From 0.26.7 an agent asks its monitor when a run stops at an approval, before it shows anybody anything, and there are three answers.
| what the monitor says | what the agent does |
|---|---|
| nothing governs this | shows its own card, exactly as it has since v0.5 |
| this rule governs it | shows no card and waits — the people the rule names decide, in the monitor's inbox |
| nothing at all | waits |
The flow says nothing about any of it. It declares approval on a node,
or approve on a work node, and which of those an organisation governs — and
by which rule — is the deployment's, exactly as requires.mcp names a
server and says nothing about where it is. So no flow changes, the DSL does
not move, and every agent already deployed becomes governable the moment its
monitor says so.
The third row is the one to understand. A monitor that is configured and silent is not permission: a card there would mean anybody who can take the link down can go round the organisation's rule. Nor is it refusal: declining a credit memo because a socket dropped is its own kind of wrong. The run waits — which is only a safe answer because of 0.26.6, below.
An agent with no monitor at all behaves exactly as it always did. That is a different answer from silence, deliberately, and it is what lets somebody develop an agent on a laptop with nothing running.
What a person sees in the chat while an approval is elsewhere is a note saying so, never a card — and it says whether the monitor could be reached, because three people are deciding this and nobody could be asked are different things to be waiting on.
The monitor's half — approver groups, rules, the inbox — is in
LGStudioMonitor/docs/features.md; the messages are in its
docs/protocol.md.
There is a third answer now — I need more (monitor 0.20.0)#
The paragraph at the top of this guide says an approver who does not know
enough has two buttons and both are wrong. That was the argument for
why, and it is no longer the whole story: since LGMonitor 0.20.0 they can
also ask, and since 0.21.0 the answer can carry a document.
What matters to a flow is what it costs, which is nothing. The question
goes to the board, not back to the chat — a run started by a trigger has
no chat and no asker — so the agent is told nothing, the run stays parked,
and the request stays pending. MONITOR_PROTOCOL did not move and the
DSL did not change.
It is not a substitute for why. A question is a round trip through a
second person; the working written into the card is already there when they
open it.
Ending one, which is not refusing it — 0.26.9#
An administrator holding approvals.override can end a request nobody
can answer: an approver who has left, a group emptied by mistake, a request
raised against the wrong rule. It travels to the agent as its own word,
cancelled, and the run stops.
It does not take the flow's onReject path. That path is what a person
refusing looks like, and a journal that could not tell an administrator
tidying up from a refusal somebody gave would have lost the only fact that
mattered. The chat says so in its own words - your organisation ended this -
and carries the reason, which is what the trail holds where a signature would
have been.
Two consequences worth knowing rather than discovering:
- A cancellation is never sent to an agent that cannot take one. It is
the
approvals-cancelcapability, not the protocol number: an agent built before 0.26.9 knows yes and no, and sending it a word it has never heard buys nothing. Its request stays undelivered and the monitor says why, which is a thing somebody can act on. What must never happen is that word arriving asnoto get it through - a refusal nobody gave, with the reject path taken and the journal recording a decision. - Nobody can answer it afterwards, including whoever still has the chat window open. The agent keeps the note as a tombstone and the door reads it. An ended request that simply vanished would be answerable again, because a conversation nothing is holding is a new conversation.
The agent asks again — 0.26.8#
A governed approval survives the agent restarting, and survives the monitor being away while it is asked. Both needed the same thing, and it took a person running the acceptance scenarios to find that neither worked.
The note saying this conversation is waiting on a decision from elsewhere
lives in .lgflow/waiting.db beside the run it is about, and the agent asks
the monitor again when it starts and whenever the link comes back. There
is no new message in the protocol: the same approval is sent again, and
the monitor hands back the same request rather than opening a second one.
What that buys, in the order it matters:
- A decision reaches a run the agent restarted into. Before this, the monitor re-delivered correctly for ever and the agent refused every time - this agent is not holding that conversation at an approval - with the run intact on disk. Three real approvals were lost that way.
- A run that stopped while the monitor was down becomes decidable. The monitor could not have heard of it; the reconnection is the first time it does. The page had already promised "it will carry on once it is", and now something keeps that promise.
- A restart no longer opens the chat's door. The guard that refuses a governed approval in the chat reads the same note, so losing the note used to mean anybody who could reach the chat could answer an approval the organisation had reserved - by restarting the agent.
One case is deliberately left alone and said out loud: if the rule has gone while a run sat under it, the run stays waiting and the agent says so on stdout. Drawing a card instead would mean drawing it on a page that was closed an hour ago, and resuming it would be a decision nobody made.
Where the wait is kept — 0.26.6#
A deployed agent keeps it in .lgflow/waiting.db, beside the journal and the
lock, so a person half-way through a form does not lose it when the agent
restarts. Until 0.26.6 every deployed agent used MemorySaver and every
redeploy over somebody mid-form threw their work away; the engine had been
saying so since v0.14 and nothing supplied the alternative.
Three things follow, and they are the things to know rather than to decide:
- The agent says which kind of wait it got, on every start.
Waiting: .lgflow/waiting.db - a form or an approval survives a restart.Where that readsWaiting: in memory, something took durability away - a Node with nonode:sqlite, a folder that cannot be written - and the agent is running exactly as 0.26.5 did rather than refusing to start. - In a container, mount the folder. The generated
Dockerfilealready documents-v <name>-state:/app/.lgflowfor the journal; the same volume is now what makes an approval survivedocker restart. Without it the wait goes when the container does. - It holds conversations, so treat it as such. It is in the generated
.gitignoreand the folder is in.dockerignore. A thread nobody touches for 30 days is forgotten when the agent next starts.
A host embedding an agent with createAgent() still gets a MemorySaver
unless it passes checkpointer: openWait(folder) from lgflow/waiting - the
folder is the host's to name, not a library's to guess.
The read node#
{
"id": "load",
"type": "read",
"from": ["{{document}}", "{{url}}"],
"output": "doc"
}from is a template or a list of them; the first that is not empty is read.
It may be an uploaded file (a file channel), a web address, or text. What
lands in output:
{ "title": "Employee Onboarding Procedure", "source": "onboarding.pdf",
"mediaType": "application/pdf", "pages": 3, "chars": 512,
"truncated": false, "text": "[page 1]\nEmployee Onboarding ..." }| Source | How it is read |
|---|---|
text per page, each marked [page N] so answers can cite it (unpdf) | |
Word .docx | paragraphs and headings (mammoth) |
| web page | scripts and styles dropped, text kept |
.txt, .md | as is |
With nothing to read the node writes nothing - so a channel marked
"remembers": true keeps the document from an earlier turn, and a follow-up
question needs no second upload. maxChars (default 400,000) caps the text and
sets truncated.
A document set by whoever builds the agent. A source with no {{ }} in it is
a fixed address: "from": ["{{document}}", "https://example.com/procedures.pdf"].
It is read silently when the conversation has no document yet, then kept - the
person never types it, and it is not fetched again for each question. A
document the person does give comes first and replaces it. Give the output
channel "remembers": true so it is kept between messages.
It refuses, with a message the person can act on: a scanned PDF (pages but
no text - it needs OCR, which this does not do), an old binary .doc,
addresses that are not http(s), and private network addresses unless the
node says "allowPrivate": true - a chat box must not become a way to read the
server's own network. Hebrew and other right-to-left documents are not handled
specially yet.
Suggested questions#
"suggestions": ["Where is ALFKI based?", "Add a new customer"]Up to eight, shown as chips under the chat box. A click puts the words in the box rather than sending them - most want a name or a number changed first.
flows/chatWithDocument.json is the whole of a document chat: a read node,
an llm node, and no generated code. The packages it needs are added to a
generated project's package.json only when a flow has a read node.