Symphonix Documentation · Core 0.61.0

Human in the loop

The points where an agent waits for a person, and who that person is.

This is the engineering reference, rendered from the repository

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:

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:

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:

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 sayswhat the agent does
nothing governs thisshows its own card, exactly as it has since v0.5
this rule governs itshows no card and waits — the people the rule names decide, in the monitor's inbox
nothing at allwaits

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:

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:

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:

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 ..." }
SourceHow it is read
PDFtext per page, each marked [page N] so answers can cite it (unpdf)
Word .docxparagraphs and headings (mammoth)
web pagescripts and styles dropped, text kept
.txt, .mdas 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.