The DSL reference
Every node type, channel, edge and requirement a flow may declare, version by version.
It is the same file the platform's own tests are written against — LGFlow/docs/reference/dsl-reference.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.
What a flow.json may say, and what each part does. One document for people and
for the model behind LGStudio's Ask bar: the overview is always in front of it,
and any topic below is one read_reference call away.
This file is checked by LGFlow's tests against the compiler itself - every node kind, channel type, field type and versioned feature must be here, and the version above must be the current one. Upgrading the DSL means updating this file in the same change; the build fails until it is.
topic: overview#
A flow is a graph run by LangGraph: state channels hold data, nodes read and write them, edges say what runs next. The whole flow is one JSON object:
{
"version": "0.33",
"id": "8f14e45f-ceea-467a-9f1a-2b0f8e5c91aa",
"name": "Order desk",
"description": "What the agent does, from start to finish.",
"conversation": true,
"suggestions": ["Where is ALFKI based?", "Add a new customer"],
"inputs": ["question"],
"outputs": ["answer"],
"requires": { "env": ["OPENAI_API_KEY"] },
"start": "reply",
"tools": [],
"state": { "question": { "type": "string", "description": "What was asked." },
"answer": { "type": "string", "description": "The reply." } },
"nodes": [{ "id": "reply", "type": "llm", "prompt": "{{history}}\nQuestion: {{question}}", "output": "answer" }],
"edges": [{ "from": "reply", "to": "END" }]
}Node kinds (type; leave it out for a handler):
| kind | what it does | needs |
|---|---|---|
handler | code generated from its description | handler, description |
llm | fills a prompt from state, asks a model, writes the reply | prompt, output |
tool | makes one named tool call with arguments from state | tool, args, output |
tools | makes every call queued in a channel (calls a model chose, or a batch) | calls, output |
fanout | runs one node once per item of a list, in parallel | over, as, to |
ask | pauses and asks the person one question | ask, output |
form | pauses and asks for several fields at once | fields, output |
read | reads a document (PDF, Word, web page, text) into a channel | from, output |
flow | runs another flow as one step, with its own state | flow, input, output |
agent | runs a registered agent chosen while the run is happening | agents, agent, input, output |
work | hands a model the tools and lets it call them until it has an answer | prompt, tools, output |
knowledge | asks a declared collection a question and keeps the paragraphs that answer it | from, ask, output |
index | builds a declared collection out of documents already read | into, documents, output |
database | runs one read against a declared database and keeps the rows, or asks what its tables are | from, sql, mode, output |
Any node may also have description, onError, retry and approval.
A flow may also declare views (0.11): cards and pictures the chat shows from
state - a score, progress, a picture - pinned above the conversation or placed
after a step, and buttons under a question that answer it. See views. How the
chat page looks - question size, fonts, colours, which buttons it shows, or a
style sheet of its own - is the flow's page section (0.12). See page.
Edges: { "from": "a", "to": "b" } always goes on; a conditional edge
{ "from": "a", "type": "conditional", "router": "pick", "routes": { "yes": "b", "no": "END" }, "when": {...} }
lets generated router code choose. END finishes the run. Every node must be
reachable from start, and every path must reach END.
Templates: {{channel}} in prompts, args, questions and read sources; a path
reads inside it - {{doc.title}}, {{customer_ids.0}}. An arg that is exactly
one template gets the channel's value (a number, a list), not its text.
Rules that hold everywhere:
- Channel names starting with
__and the node idsSTARTandENDare reserved. - Declare every channel a node reads or writes, with a type and a description.
- Every message a person sends is its own run; channels start from their
defaults unless marked
remembers. A conversational flow keeps a transcript inhistoryby itself. - What the person gives at the start is an input - never a node that "asks for" or "collects" it. Never a node that only "shows" an output either: outputs are shown when the run ends, and anything to show while it runs is a view.
- A run that needs particular details before it can begin - a customer id, a
date range, several fields at once - takes no inputs and opens with a form
instead:
"inputs": []and aformnode asstart. Seeforms. - A job another agent already does is a flow of its own, run as one step:
{ "type": "flow", "flow": "./flows/order-lookup.json" }. Seesubflows. - When which agent depends on the question, name the ones it may call and let
the run pick:
{ "type": "agent", "agents": ["sql", "documents"] }(0.19). Seeagents. - What starts a flow - a clock, a named event, an HTTP request - is not in
flow.json at all: it is a
triggers.jsonbeside it (0.20). Seetriggers. - Handler, router and tool code is generated from descriptions, so descriptions must be exact: what is read, what is written, in what shape.
Topics, for the details: channels, handlers, llm, tools, work, forms,
approval, edges, errors, fanout, ask, read, knowledge, databases, conversation,
contract, services, files, recipes, patterns, views, page, shell, subflows,
agents, triggers, identity, tests, evals, types, versions.
topic: channels#
state maps each channel's name to what it is:
"state": {
"question": { "type": "string", "description": "What the person asked." },
"results": { "type": "list", "reducer": "append", "description": "One entry per search." },
"budget": { "type": "number", "default": 1, "remembers": true, "description": "Dollars left." },
"photo": { "type": "file", "accept": "image/*", "description": "The picture to read." }
}type- one ofstring,number,boolean,list,object,any,file.description- what it holds, in the shape code will see it. Generated code is written against these words.reducer- how a second write combines with the first:replace(default),append(lists: each write adds items),merge(objects: each write adds keys). A channel written by several parallel copies (fan-out) must useappend. Appending gathers within one run; it does not carry anything into the next message. Onlyremembersdoes that, and a channel that should accumulate across a conversation says both.default- where it starts.remembers: true(0.9) - keeps its value into the next message of the conversation. Anything counted "so far", a budget, a document already read.accept- on afilechannel only: what a person may choose, e.g."image/*"or".pdf,.docx".
history exists by itself in a conversational flow (see conversation).
__approval, __error and __visits are LGFlow's own; code and prompts may read
the first two ({{__approval}}, {{__error}}), which start empty every message.
topic: handlers#
A node with no type is a handler. Its code is generated from the description:
{ "id": "rank", "handler": "rankResults",
"description": "Reads 'results' (a list of { title, url, score }). Drops entries with no url, sorts by score, highest first, keeps the top 5, and writes them to 'top'." }The generated function is async rankResults(state, { services, settings, env })
and returns only the channels it changes. Write the description so the function
can be written from it alone: which channels it reads, the exact shapes, what it
writes, and what happens when something is missing ("If 'results' is empty,
write 'top' as []"). Text in backticks is copied exactly. "Process the results"
is not a description.
With one exception, and it is the whole of 0.53: never write out the shape
of a channel that a database or knowledge node wrote. Name the channel
and say what to do with it; the engine supplies that shape to the code
generator itself, read out of the engine at the moment the unit is written.
A description is prose somebody typed once. The shape is the engine's, and it
moves — dropped and renamed arrived at 0.44, say at 0.46. A description
that recites it is a second copy of a contract with nobody holding the two
equal, and the copy loses. Measured: a handler description in this tree
recited a database result and was nine versions behind by the time anybody
read it, so the generated code never looked at say and the agent reported a
withheld column as data that was not there.
So this, and not a list of fields:
{ "id": "report", "handler": "writeReport",
"description": "Reads 'found', which a database node wrote. Writes one paragraph to 'reply' naming how many rows there were in all, not how many are in hand. If the result says nothing was found, say so in that paragraph and name no number." }The generator is handed found's real fields beside this, under a heading
saying they come from the engine and that where a description disagrees with
them the description is out of date.
That was not enough on its own, and the shape of what was added matters to anybody writing a description. This passage used to end by saying the one thing a description must still do is say what to do when a field is there — and that is where it was measured and found wanting. A flow was redeployed against the injected shape and the code generated from it still wrote a report that lost a column in silence: the description gave per-field lines to write, the engine gave a declaration and a note about precedence, and a model holding both writes the lines it was told to write.
So the engine now also states, in the imperative, what the generated code must do with the few fields a person suffers for when they are dropped: that a channel a person reads has to say when a column was withheld, by name, *even when the rows came back full*, and the same for a column shown under another name. A description does not need to ask for any of that, and a description that says nothing about it is not read as a decision not to do it. What a description still chooses is the wording and the channel — and the intent for everything else, which only the flow knows.
A node that needs neither code nor a model - reading a document, asking the
person - is a read, ask or form node instead, with nothing generated.
topic: llm#
{ "id": "reply", "type": "llm",
"prompt": "Answer from the facts below.\n\nConversation so far:\n{{history}}\n\nFacts: {{facts}}\n\nQuestion: {{question}}",
"output": "answer" }The prompt is filled from state and sent to the model; the reply text is
written to output. Objects and lists in templates are written as JSON. Which
model, and its key, are the project's (OPENAI_API_KEY) - never in the flow.
When the answer is data, declare its shape (0.15). schema says what the
model must send back, and only an answer that fits it is written:
{ "id": "classify", "type": "llm",
"prompt": "Classify this request:\n\n{{question}}",
"output": "classification",
"schema": {
"type": "object",
"properties": {
"category": { "type": "string", "enum": ["technical", "billing", "general"] },
"confidence": { "type": "number", "minimum": 0, "maximum": 1 },
"reason": { "type": "string" }
},
"required": ["category", "confidence"]
} }state.classification is then { category: "billing", confidence: 0.94, reason: "..." },
and a router can read classification.category instead of reading a sentence.
Three fields, three jobs. Never do one of them twice:
prompt what the model should do
schema the exact shape it must answer with
output which channel that answer lands inLGFlow puts the schema to the model itself, along with the prompt. So a prompt that also says "return JSON", "and nothing else", or repeats the property names is saying the same thing twice, in words that will drift from the schema the first time either is edited. Write the prompt as if a person were answering. The model is told what the answer starts with and that the schema describes the answer rather than being it; a reply that sends the schema back with the values inside it is refused, and the failure says so rather than leaving it to be worked out.
- A schema may say
type,description,properties,required,items,enum,minimum,maximum,minLength,maxLength. Nothing else - a keyword LGFlow does not know is refused by name, not ignored. Types are the DSL's own:string,number,boolean,object,list. Alistmust say what itsitemsare; an object must say itsproperties, andrequirednames some of them. Schemas nest as deep as the answer does. - The schema and the channel must agree. An
objectschema needs anobjectchannel (orany); alistschema alistchannel. Otherwise:LLM node "classify" schema type "object" is incompatible with output channel "classification" of type "string". - An answer that does not fit is a failure, not data. Nothing is written -
not even the part that fitted - and the node fails like any other, so
retrytries again andonErrorcatches it, with the reason in__error. The message names the field:classification.category must be one of: technical, billing, general,tasks[2].priority must be a string. false,0,"",[]and{}are answers. Only a property that is absent, or null, is missing.- Without a schema an llm node behaves exactly as it did before 0.15, below.
When it cannot answer without asking, let it ask (0.17). "asks": true
lets a node stop and put a form in front of the person instead of writing a
paragraph asking 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 the person 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 is told to copy its fields, which is what turns a tool's own
inputSchema into a form nobody had to write out.
- Add it to the node that decides, not to one that only reports.
- A reply that is not a request for fields is the answer, exactly as before - switching this on cannot turn a working node's output into an error.
- It asks at most three times in one visit, then answers with what it has.
- Say what a cancel means and it is said, not worked out:
"onCancel": "Action cancelled."writes those words into what the flow shows and ends the turn there - no further node, and so no model call at all. Without it the run carries on and whatever the flow answers with says what happened. - Closing the form does not fail the node, and does not go back to the model: the node writes nothing and the run carries on, so the channel keeps what it held and the flow’s own answering node is what tells the person. Someone who has just closed a card is waiting to be let go, not to pay for another round trip. 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 - and kept across requests, since a served agent compiles the flow again for every one of them. A turn with one form costs two model calls, not three, and a cancelled turn costs one. The person's answers cannot be thrown away by a model that changes its mind on the second telling.
For a model that must return data without declaring a schema - the way it was
done before 0.15 - ask for "one
JSON object and nothing else" (or one JSON list), show the exact shape, and
declare the output channel as object or list: LGFlow then reads the JSON out
of the reply - code fences and a sentence around it are fine - and the channel
holds the data itself. A reply with no JSON of that shape fails the node, which
onError can catch. A quote the writer meant - Hebrew abbreviates with one,
ע"י - is escaped rather than failing the parse, so a model writing Hebrew or
Arabic inside JSON still reads. A string channel keeps the reply as text. One copy of a fan-out writes
its own item, so an llm node a fan-out targets, whose output is an append
list, may answer with a single JSON object - its entry - and it is appended.
A list still works. Ask each copy for one object: it is the shape the prompt
wants anyway, and asking for a list of one invites a list of many. A prompt that answers the person, or decides
what their question means, should read {{history}}.
Tables. The chat page shows a Markdown table in an answer as a real table -
the text around it stays text - and an output that is a list of records (a
list of objects) as a table with one column per field. So when an answer
lists several things that share the same facts - emails, orders, events,
customers - the answering prompt should say so: *"When you list several items,
show them as a Markdown table with the columns Date, From, Subject, then one
short line of summary."* Name the columns; keep cells short. One item, or a
plain answer, stays prose.
An output a trigger delivers (0.20) is the exception, and the reason is that
a chat channel is not a web page: write it as a list of records with a
schema and let each surface format it. The chat still draws a table; Slack,
Teams and Discord would print the pipes of a Markdown one - topic triggers.
topic: tools#
tools lists what the flow may call. Each tool's code is generated from its
description:
"tools": [{
"name": "getCustomerDetails",
"description": "Reads one customer through the mcpDb MCP server: services.mcpDb.callTool(\"getCustomerDetails\", { customerId }). Returns the customers row as an object.",
"inputs": { "customerId": { "type": "string", "required": true, "maxLength": 5, "label": "Customer id" } }
}]A tool node makes one call:
{ "id": "lookup", "type": "tool", "tool": "getCustomerDetails",
"args": { "customerId": "{{customer_ids.0}}" }, "output": "customer", "onError": "answer" }A tools node makes every call queued in a channel - [{ "name", "args", "id"? }]
- and writes
[{ "id", "name", "result", "error" }], one per call. One failing
call does not stop the others; its error is in its own entry. The queue is
emptied afterwards ("clear": false keeps it). Use it when a model picks the
tools, or to make a batch of calls (one per customer) behind one approval.
A tool description must say which service it calls and how (see services,
recipes), and end by saying exactly what it RETURNS, in the shape of the
channel the calling node writes.
inputs (0.10) declares the tool's arguments as fields (see forms). They are
its arguments exactly: pass them flat in args, never wrapped in one object. A
call missing a required one - or holding a value the field refuses - stops the
run with a form, and nothing is called until it passes. Pass what you know,
leave out what you do not; never invent an id, an address or a price to avoid
asking. A tool may declare only the arguments a person should see; the rest
pass through untouched.
topic: work#
A work node (0.24) hands a model the tools and lets it call them itself, one
after another, until it has an answer:
{ "id": "answer", "type": "work",
"prompt": "Answer the question using the Northwind database.\n{{history}}\nQuestion: {{question}}",
"tools": ["northwind"],
"output": "answer" }tools takes server names - every tool the flow declares on that server
(see services) - or individual tool names, to give this node less than the
flow has. A name that is both an MCP server and a tool is refused: rename one.
Nothing here is MCP-only; a flow's own tools work the same way.
rounds caps how many times round the loop (8 by default). Reaching it fails
the run by name rather than returning half an answer.
The server's own words and fields reach the model. A tool declared by name
alone is still fully described to it: the MCP server is asked once, and what it
says each tool is for and what it takes is what the model is shown. Anything the
flow says - a describe, an inputs field - wins over the server's version of
the same thing, and the rest is the server's, unchanged. So declaring by name is
not declaring less, and retyping a server's sentences into the flow buys nothing
but a second copy to keep in step.
A missing argument is a form, not an error, and the node is built so that the model reaches it. Those fields are shown with nothing marked required, and with a note saying that whatever it leaves out will be put to the person and the call then run. So you do not write any of this into a prompt: a node asked to add a customer with no details calls the tool with none and the person gets the form. Required still holds where somebody is actually asked, on the form itself.
approve lists the tools that need a person first. The run pauses at that
call and goes on with the model's own work when the answer comes back - which
is what the node is for: let it read freely, stop it where something changes.
The card shows the tool and the arguments the model chose; ask is the question
on it, and show names channels to put beside it. A refusal is not an error -
it comes back to the model as that call's result, so it can say what it did not
do. A flow with any approve needs somewhere to wait, like every other pause.
{ "id": "desk", "type": "work", "prompt": "{{question}}",
"tools": ["northwind"], "approve": ["addOrder"],
"ask": "Place this order?", "show": ["question"],
"rounds": 12, "output": "answer" }Prefer this to building the loop out of nodes. The alternative - an llm
node that writes a plan, handlers that turn the plan into arguments, a tools
node that calls, and a second llm node that writes the reply - is nine nodes
where this is one, and it has a fault no prompt fixes: the model that writes the
answer is not the model that called anything, so when the channels it is handed
come back empty, nothing contradicts it and it describes work that never
happened. Here the model that answers is the model that called, and a tool it
needs and has not got is reported rather than imagined.
Use tool or tools nodes instead when the flow, not the model, decides what
to call - a fixed lookup, a batch of one call per customer, a step that must
happen exactly once.
What tools lists is a ceiling, not a promise. From monitoring 0.8 a board
switches each MCP tool on or off per deployed agent, while it runs - so name
here everything the node may ever need, and never write a prompt that assumes a
particular tool will be there. A call the board has not switched on is refused
before it goes out and before any form is raised, and the node reports it as a
tool that is not available. The switch can only narrow this list, never add to
it: a tool the flow does not name cannot be turned on by anybody.
topic: forms#
Fields are how a flow asks for things (0.10): a tool's inputs, a form node's
fields, and an approval's editable channels all use them.
"customerId": { "type": "string", "required": true, "maxLength": 5, "label": "Customer id",
"description": "Five characters, e.g. LABS5." }Field types: string, text (several lines), number, boolean, date
(YYYY-MM-DD), list, object, any, file. A field may have required,
label, description, options (a list of the values allowed - shown as a
choice), default, accept (files) and maxLength (text).
A list of like entries is a list with of, shown as rows with "+ Add" - never
ask a person to type JSON. Row fields are plain: string, text, number, boolean,
date. Empty rows are dropped; a half-filled row is refused by number.
"items": { "type": "list", "required": true, "label": "Products",
"of": { "productId": { "type": "number", "required": true },
"quantity": { "type": "number", "required": true },
"discount": { "type": "number" } } }Every submission is checked on the server; a refused value comes back on the
form with what is wrong beside it. Cancel on a form is an error for the node,
so onError decides what happens next.
Opening with a form. A flow that needs particular details before it can do
anything declares no inputs and makes a form node its start: the chat opens
straight into the form, with no message to type and no Start button.
"inputs": [],
"start": "ask_customer",
"nodes": [
{ "id": "ask_customer", "type": "form", "title": "Which customer?",
"fields": { "customerId": { "type": "string", "required": true, "maxLength": 5, "label": "Customer id" },
"since": { "type": "date", "required": true, "label": "Orders since" } },
"output": "details" },
{ "id": "look_up", "type": "tool", "tool": "getOrders", "args": { "customerId": "{{details.customerId}}" }, "output": "orders" }
]Use it whenever the person would otherwise have to type several things into one message, or type something exact (an id, a date, a choice from a list) - a form checks those, a sentence does not. One free-text question is still an input: a chat that starts by asking "what do you want to know?" needs no form. The same node kind asks again later in the run, whenever the flow needs more.
A form node asks at a chosen point and writes the answers as one object:
{ "id": "details", "type": "form", "title": "Where should we ship it?",
"fields": { "address": { "type": "text", "required": true }, "express": { "type": "boolean", "default": false } },
"output": "shipping" }In a tools node every queued call is completed - forms answered - before any
is made, and before its approval is asked. So a write that may lack details is
a tools node over a queue of calls: form first, then the approval card of the
finished thing. A flow with forms, approvals or ask nodes needs a checkpointer;
generated projects have one.
Fields the flow does not know yet. What is worth asking for is not always
known when the flow is written: an MCP tool says what it takes in its own
inputSchema, read at run time. A form's fields may be one template naming
the channel that holds them (0.17), and a JSON Schema there is read as fields -
required marks them required, enum becomes a choice, maxLength is checked,
array becomes rows with "+ Add":
{ "id": "collect", "type": "form", "title": "Add a customer",
"fields": "{{tool_schema}}", "output": "given" }The channel must hold something to ask for; a form that would open empty is a node failure, like any other, and says which channel was empty. A flow that knows its fields still writes them out - this is for the ones it cannot.
topic: approval#
Anything that writes - places an order, adds a record, sends, pays - waits for a person first:
"approval": { "ask": "Place this order in the Northwind database?",
"show": ["order_calls"], "edit": ["order_calls"], "onReject": "write_answer" }- The run stops before the node does anything; refused means it never ran.
shownames the channels the person sees - never the whole state.edit(0.10) names channels the person may correct on the card before approving; the corrected values replace them, checked like a form. On atoolsnode, editing its queue shows the tool's own form - one call as its fields, several calls to one tool as rows - never JSON, when the tool declares inputs. Astringchannel whose value already has lines in it is shown as a box with lines, not a single-line field (0.17): a one-line field shows a multi-line value on one line and hands back what it shows, so a SQL query approved without a word being changed came back with its newlines gone. A person may also answer the card by typing: "yes" approves, anything else declines, and what they typed is in__approval.answer.onRejectis where a refusal goes. After it,state.__approvalis{ node, approved: false, answer }. The answering prompt should read{{__approval}}and say plainly that the person declined - not that "the data does not say why". It is empty on a message with no approval.- An approval on a
toolsnode with an empty queue is not asked: there is nothing to approve.
needs (0.27) names the policy this approval asks for:
"approval": { "ask": "Pay this invoice?", "show": ["amount"], "needs": "two-eyes" }- It is a name you choose, and it says nothing about which people may
approve, how their answers are counted, or whether anybody is listening.
Those are the deployment's, and the same flow deployed in three places may
be governed three ways or not at all - the split is the one
requires.mcpuses, where a flow names a server and says nothing about where it is. - Nothing validates it, here or at deploy. A flow cannot know what an
installation has mapped, and one that checked would be wrong everywhere
else. A name nobody has mapped is the monitor answering - it says which
name it was - and the approval then goes wherever that monitor says for
this agent, exactly as a flow with no
needswould: a rule pinned on the agent if there is one, and the agent's own card if there is not. That is not the same as a monitor that is configured and silent, which is the case a run waits on. - A flow that says nothing is unchanged: where its approvals go is decided on the monitor, by agent, exactly as before.
- On a
worknode,needssits besideapprove- one policy for the node, not one per tool:
{ "id": "desk", "type": "work", "tools": ["northwind"],
"approve": ["addOrder"], "needs": "two-eyes" }why (0.28) names one channel holding the agent's own account of how it
got here:
"approval": { "ask": "Pay this refund?", "show": ["amount"],
"why": "case_notes" }- It is the case for the decision, and
showis the decision's own figures. They are drawn apart on every card for that reason: a person who already has the context needs the amount and nothing else; a person who does not needs the case, and making everybody read a paragraph to find a number is how a reviewer learns to skip paragraphs. - One channel, never a list.
showis a list because fields are a list. A case drawn as two more labelled values is not a case. - A
stringchannel holds a paragraph; alistchannel with"reducer": "append"holds one line per step, joined in order - which is usually what you want, because the order is the reasoning. - It is read when the run stops, so two refunds through one node carry two different cases. Anything that is not words, and anything empty, is nothing at all: no heading is drawn, because a heading over blank space reads as there was no reasoning.
- It is shown as the agent's account and labelled as that wherever it is drawn. Nothing here can make it true; what it does is carry the flow's own words rather than a summary of them.
- It travels with the question to whoever decides, and is part of what the approval's digest covers - approve one case and the agent cannot act on another.
exhibits (0.29) names the documents behind the decision - a list of
file channels:
"approval": { "ask": "Pay this refund?", "show": ["amount"],
"why": "case_notes", "exhibits": ["invoice", "bank_statement"] }- Three kinds of thing reach a card and they are not one list:
showis what is being approved,whyis why you are being asked, and these are the evidence. An approver has to be able to tell them apart, and 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. - Every name must be a channel of type
file. Astringchannel named here is refused at compile: a row saying there is a document where there is none is worse on an approval card than anywhere else. - Read when the run stops, like the account: these are this run's documents. A named channel holding nothing is left out rather than drawn as an empty row.
- The bytes do not travel. What goes to whoever decides is each document's name, type, size and SHA-256; the bytes stay in the agent's own file store until an approver opens one, and then they come back over the link the agent already has. A board never stores a customer's document, which is what makes how long is it kept a question with no answer needed.
- 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 - which is the bug the digest exists for, and the worst version of it is the one with a paragraph of reassurance printed above it.
- A document the agent cannot read is still listed, marked as unreadable, and still in the digest. It is evidence that went missing, and a card that quietly showed one fewer exhibit would be losing it twice.
- An agent that is stopped is a document that will not open: the name, the type and the fingerprint are on the card, and the bytes are in a folder nobody is serving. Every card that can says so in those words.
topic: edges#
A plain edge always goes on: { "from": "a", "to": "b" }.
A conditional edge chooses; the router is generated from the when sentences:
{ "from": "check", "type": "conditional", "router": "routeCheck",
"routes": { "ok": "work", "exceeded": "over_budget" },
"when": { "ok": "'spent' plus this photo's estimate is within 'budget'",
"exceeded": "anything else" },
"limits": { "ok": { "max": 3, "then": "exceeded", "per": "start_turn" } } }- Route names are short and lowercase. Every route needs a
whensentence saying exactly what the router checks; the last is often "anything else". limits(0.2): take a route at mostmaxtimes, then take the route named intheninstead - a route name, not a node id.per(0.3) starts the count again whenever that node runs - "two tries per word". Limits need the flow to declare state.- Give a node one way out: one plain edge, or one conditional edge.
- A router branches on settled state, never on what somebody meant. Its
code is generated, and code cannot read intent - the words it was not written
for are exactly the ones it will be given. A route that depends on what a
person said is decided by a model first: an
llmnode writes one word to a channel, and the edge routes on that channel.
{ "id": "classify", "type": "llm", "output": "kind",
"prompt": "Answer with one word, \"order\" or \"customer\", for: {{question}}" }{ "from": "classify", "type": "conditional", "router": "routeTurn",
"routes": { "order": "parse_order", "customer": "customer_lookup" },
"when": { "customer": "'kind' is \"customer\"", "order": "anything else" } }The decision is then visible in the flow, costs what it costs in the open, and appears in the journal like any other model call. A generated router that matches patterns is refused at generation; one written this way is not.
- Name what the router reads in single quotes, as every example above does:
the channel or field being read in single quotes, the value it is compared
against in double -
'kind' is "customer". Backticks are accepted for the name as well. This is checked and not only a house style: readiness warns (MCP_ROUTE_IS_A_GUESS) when a route reaching an MCP server is chosen by generated code, and the way it tells that a model decided instead is that awhensentence names a model's channel this way. A field counts when the node that wrote the channel asked for it by name - declared in that node'sschema, or asked for as a JSON key in its prompt - so a router readingplan.wantout of an object anllmnode wrote says'want'. *Until 2026-11-07 the sentence was searched for a bare channel name, which an English word switches off: a sentence ending "answer from the question and the conversation alone" containsanswer, andansweris a channel in most flows. Measured over every flow on the author's machine before it changed.*
topic: errors#
{ "id": "lookup", "type": "tool", "tool": "getCustomerOrder", "args": {...}, "output": "facts",
"retry": { "maxAttempts": 3 }, "onError": "write_answer" }retry(0.3) runs the node again when it throws, up tomaxAttemptsin all.onError(0.3) sends a failure to another node instead of ending the run.state.__errorthen holds{ node, message }for the node that recovers, so it can say what went wrong - a prompt reads it as{{__error}}. It is cleared when a node succeeds.onError: "SKIP"(0.32) is the one value that is not a node id: it means this copy contributed nothing, carry on, and is allowed only on a node a fanout sends to. The copy writes nothing, the run goes on with the others, and the skip is traced. There is no__errorfor it - a skipped copy is not a run that failed, and a flow whose onlyonErroris the word has no error channel at all.- Without
onError, a throw ends the run.
Why SKIP exists, and why only there. onError names a node to go to,
which is right everywhere except inside a fan-out: over real data, one
unreadable document in five hundred ended the whole run, because there was no
node that meant forget this one and keep the other thirty-nine. Outside a
fan-out the word has no meaning worth having - carry on having done nothing
is a node that succeeded while doing nothing - so it is refused there
(SKIP_OUTSIDE_FANOUT).
{ "id": "spread", "type": "fanout", "over": "documents", "as": "document", "to": "read_one" },
{ "id": "read_one", "type": "read", "from": "{{document}}", "output": "texts", "onError": "SKIP" }topic: fanout#
{ "id": "spread", "type": "fanout", "over": "urls", "as": "url", "to": "fetchPage" }Runs to once for each item of the list in over, all in the same step, each
copy seeing its item as as. Every copy writes the same channel at the same
moment, so that channel needs "reducer": "append". An empty list runs nothing
and the branch ends there - route around it when nothing matched.
Routing around nothing is the shape above, written out. It needs no new field: the node in front of the fan-out takes a conditional edge, and one route goes to the tail instead.
{ "from": "gather", "type": "conditional", "router": "routeBatch",
"routes": { "review": "spread", "none": "summarize" },
"when": { "review": "'documents' has at least one document in it - there is something to spread over",
"none": "anything else: 'documents' is empty, so go straight to the summary rather than fanning out over nothing" } }The tail is then reachable without the fan-out, and an empty list costs only the per-item work - which is what an empty list is for. Without it, the nodes past the target never run: not an error, and nothing in the journal says the person got no answer because a list was empty. Readiness says so as FANOUT_MAY_SILENCE_THE_TAIL, which is advice rather than a refusal.
Two details decide whether this works. Route from the node before the fan-out, not from the fan-out - a fan-out has no edge of its own, by the rule below. And route to the node that runs once after the items, which is often not the last node: a flow that gathers, then reports, routes to the gatherer, because the report reads what the gatherer writes.
The fan-out node has no edge of its own. Its copies are what carry the run
on, so what comes next goes after the target: { "from": "fetchPage", "to":
"gather" }. An edge leaving the fan-out runs in the same step as the copies,
before any of them has written, so the gathering node sees an empty list -
readiness flags it as FANOUT_HAS_EDGE.
topic: ask#
{ "id": "quiz", "type": "ask", "ask": "What is in the picture?", "show": ["picture"], "output": "guess" }Pauses the run and puts one question to the person; the reply is written to
output as text. show (0.10) names channels shown with the question - a picture
for "what is in the picture?", a document, a value: "show": ["current_image"].
A question about something the person must see needs it; without it they see
only the words. Use it only for something the agent learns it needs after
starting - a choice between results, a confirmation of a guess. What the person
gives at the start is an input, never an ask node. Several fields at once are a
form node.
A question that lists things to choose between - orders, rooms, dates, any set
of things with the same facts about each - is written as a Markdown table, the
same as an answer that lists things (see llm): a header row, the | --- | rule
under it, one row per thing. The chat page draws it as a table. Build it in a
handler, into a channel of its own, and read that channel into the question:
"ask": "Which order would you like to cancel?\n{{choices}}\nGive its number."
topic: read#
{ "id": "load", "type": "read", "from": ["{{document}}", "{{url}}", "https://example.com/procedures.pdf"],
"output": "doc" }Reads a document into output as
{ title, source, mediaType, pages, chars, truncated, text }. PDFs are read page
by page with [page N] markers, so answers can cite pages; Word .docx, web
pages (scripts and menus dropped) and text are read too. maxChars (default
- caps the text;
allowPrivate: trueallows addresses on this machine. - Sources with
{{ }}are what a message brings - afilechannel the person uploads, or a URL they give. The first with something in it is read. - A source with no
{{ }}is a fixed document set by whoever builds the agent. It is read silently when the conversation has no document yet, and kept; the person never types it. For a chat about one document, use only the fixed address and take no url input. - With nothing new to read the node writes nothing, so the channel keeps the
document read earlier - give it
"remembers": true. - Refused, with a message the person can act on: scanned PDFs (no text), old
.docfiles, non-http addresses, and private addresses unless allowed.
A document chat: inputs question (string), document (file, accept
".pdf,.docx,.txt,.md,.html") and, if people may give links, url (string); a
doc channel (object, remembers); the read node first; then an llm node whose
prompt gives {{doc.title}}, {{doc.text}}, {{history}} and {{question}},
says to answer only from the document, to say plainly when it does not say, and
to cite the page. No generated code at all.
topic: knowledge#
A collection is a body of documents indexed in advance. read takes one
document somebody handed this conversation and puts the whole of it in the
prompt; a collection is asked a question and gives back the handful of
paragraphs that answer it - so the cost of a question does not grow with the
size of the corpus, and nobody uploads anything.
Declared, never described (0.30), exactly as requires.mcp is:
"requires": {
"knowledge": {
"hrPolicies": { "describe": "The company's HR policies and handbook." },
"personnel": { "describe": "Staff files.", "reader": "required" }
}
}- Nothing here says where the index is, what embedded it, or what that
cost. No address, no path, no model, no token - that is the deployment's,
from
.env, and adescribethat names a machine is refused withINVALID_DESCRIPTION. Say what the collection holds. - Nothing about vectors reaches the flow. A question goes in words and paragraphs come back in words, each with its source. That the finding is done by comparing embeddings is the deployment's business and appears nowhere in the DSL.
- The name must work as
services.<name>, sohrPolicies, nothr-policies.
In LGStudio, use_collection is how this is written - it declares the
collection and builds the pair below, because a declaration with nothing
asking it is a flow that looks finished and answers nothing. There is no
other tool that writes requires.knowledge, and for three versions there
was none at all: a model could build the node, could not declare what it
named, and looped on COLLECTION_NOT_DECLARED with no way out.
Asking one: the knowledge node, from 0.30#
{ "id": "find", "type": "knowledge", "from": "hrPolicies",
"ask": "{{question}}", "most": 5, "output": "paragraphs" }Writes a list of { text, source }. most is 1 to 50, five when it does
not say.
And no score - deliberately, and it is the one field somebody asks
for. A passage's score does not travel out of the collection, so there is
nothing downstream to rank on and nothing to threshold on. The floor is the
collection's, set where it was measured, and a flow that applied a second
one would be filtering on a number whose scale it has never seen - two
embedders disagree about what 0.4 means, and the index can be rebuilt in a
different model without the flow changing a line. Ask for fewer passages
with most instead.
Two nodes, not one. This finds; an llm node after it answers. A single
node that did both would return a paragraph, and then nothing could tell a
bad answer caused by a missed paragraph from one caused by the model. Separate,
the finding is visible in the trace and scoreable on its own.
Nothing found is not an error: the channel gets one paragraph saying so, and that paragraph carries the words to say. It holds what happened — the documents were searched and read against the question, or nothing came back at all — and then the sentence the person is to be given:
Sorry, based on the provided data source, I cannot reply.It is on the paragraph as reply as well as inside the text, so anything
that answers without a model takes the field rather than reading it back out
of the sentence. And the paragraph says which kind it is: nothingFound
is true on it, so a flow that routes on did anything answer reads one
field rather than matching the sentence - which is the half most likely to be
reworded. A database node writes the same nothingFound for the same
reason, so one route reads both.
This used to say that the prompt after it should be told to say so plainly rather than to guess, which made it a line every flow author had to remember. Told only the fact, a model can still write *the articles do not address leave directly, but it may be inferred from clause 14* — which is the thing the whole refusal exists to prevent, arriving one step later.
One sentence for both kinds of nothing. It was read and does not answer
and nothing came back are two different things to go and fix — a judge's
verdict, against a floor set too high or an index never built — and they are
the same answer to the person asking. Which one it was stays on the paragraph
as refused: true, false, or null for a collection nobody asked.
reader: "required"#
For a collection whose paragraphs depend on who is asking - staff files, one person's records. The run's user is sent with the question, and the collection filters.
- A flow declaring it must also set
"requires": { "users": true }, or it is a validation error (KNOWLEDGE_NEEDS_USERS) - a flow that does not identify its people would refuse every question it was ever asked. - A run that does not know who it is for does not ask at all. The question never leaves. Nobody has decided must never be read as everything.
- Leave it out for a collection that reads the same for everybody.
Building one: the index node, from 0.31#
A flow may also build the collection it declares. One more word on the same declaration, because an installation that has a collection has one collection whichever direction a flow uses it in:
"requires": { "knowledge": { "hrPolicies": { "describe": "…", "builds": true } } }{ "id": "build", "type": "index", "into": "hrPolicies",
"method": "fixed", "size": 1000,
"builtFrom": "{{builtFrom}}", "output": "built" }Writes { passages, model, dimensions } - what was built, and by which
embedding model.
builds: trueis the permission, the wayrequires.mcp's tool list is. Anindexnode pointing at a collection the flow only asks is refused (COLLECTION_NOT_BUILDABLE) - almost always a flow somebody copied, and the failure it would otherwise cause is a rebuild of a collection other agents are answering from.- There is usually no
documentsat all, and that is the ordinary case: the collection indexes its own folder, fromKNOWLEDGE_<NAME>_FOLDERin the deployment's.env. A folder is a fact about one machine, so a flow that named one would stop working the first time it was installed anywhere else - and it is what keeps a webhook from choosing what gets indexed, since there is nowhere in the DSL to put an address. documentsis for when the flow already has them - a person uploaded one, or a fan-out ofreadnodes fetched them - as a channel of{ title, source, text }. Never addresses.builtFromis a phrase for a person - the HR share, *the policy site* - never a path. It is written into the collection's record and read by people, and a trigger usually supplies it.- A build replaces. There is no incremental index.
- When the agent reports to a board, the build is reported too: the collection, the model, the paragraph count. The board compares the model and records a change as something that went wrong, with both names in it - so swapping a local embedder for a hosted one cannot happen quietly.
method - how a document becomes paragraphs#
method names a strategy and size is characters per paragraph (100 to
20000, default 1000) for the ones that count. How to split is a
behaviour and belongs in the flow; where the documents are does not.
| method | what it does | |
|---|---|---|
fixed | split every size characters, never mid-word | 0.31 |
structure | the document's own headings, clause numbers and lettered sub-clauses; whole sections packed to size | 0.32 |
recursive | paragraphs, then sentences, then words as needed | planned |
sentence | whole sentences grouped to a size | planned |
semantic | split where the subject changes | planned |
sliding | overlapping chunks | planned |
parent-child | index the small chunk, return the section around it | planned |
agentic | ask a model where the boundaries are | planned |
Naming a planned one is a validation error (METHOD_NOT_BUILT) saying
so - not a deploy that fails when the trigger fires at two in the morning.
The whole vocabulary is in the language on purpose: the plan cannot drift
from the code, because the plan is the code.
fixed is deliberately crude - it is the only one whose behaviour a person
can work out from the name, so when retrieval is poor the chunking is not
the thing to suspect. Its weakness is real: a fact spanning a boundary is
split across two chunks, and neither then answers the question on its own.
structure is that weakness answered, and it was answered because the
weakness cost an answer. On a 23-page articles of association at
fixed/350, one right ran about 2,000 characters and became six paragraphs;
asked the question it answers, the piece naming an exception to it ranked
first of 41 and the piece that granted it was outside the top fifty
of 129 - and no depth reaches what the floor has already removed. Through
the agent's own prompt the answer was fluent, confident, and about the
wrong clause, three times out of three.
So structure splits where the document says it divides - a markdown
heading, a numbered clause, a lettered sub-clause - and packs whole sections
up to size rather than counting characters through them.
It is a trade, not an upgrade, and the trade was measured. Four
questions put to a real agent against the same articles, three rounds each:
structure with most: 10 answered the question fixed could not - and
answered it specifically, with the clause's own 4% exemption - at 31-59%
fewer prompt tokens. It also lost one that fixed answers, and no window
brought it back.
The reason is the same in both directions. A clause-bounded paragraph is precise: when the right clause is among the ones returned, the answer is sharper and cheaper, because nothing around it is noise. When it is not, there is no safety net - a fixed cut leaves neighbours that may straddle the answer, and a clause does not.
So choose it per collection, not as a default: it suits a numbered document
asked precise questions, and fixed suits one where the answer may be
anywhere near the match. Neither is a fix for a paragraph the ranking did not
return at all, which is what both of them were still missing. Two rules, both
about what it will not do: a section is never split while it fits, and a
section longer than size is cut by fixed within itself rather than
at a boundary this strategy invented. A document with no marks comes
back exactly as fixed would cut it - prose with no headings has no
structure to follow, and pretending otherwise is a worse index that looks
like a better one.
A nightly indexer is a triggers.json with a schedule, one index node and
nothing else - no read node and no fan-out, because the folder is the
deployment's. See examples/document-indexer/.
topic: databases#
A database a flow may read, from 0.33. A name and a sentence:
"requires": { "database": { "northwind": { "describe": "The order book. Orders, customers and products." } } }That is the whole declaration. No host, no port, no driver, no user and no
password - where that database is, which process holds its credential and
what that credential may do are the deployment's. The board resolves the
name at deploy and writes DATABASE_NORTHWIND_URL into the project's
.env, by the same rule that turns hrPolicies into
KNOWLEDGE_HR_POLICIES_URL.
The connection is the permission, the way a collection is - there is no
list of tables under it. Narrowing which tables is the credential's job: a
read-only user with SELECT on what it should see. The board's Test
connects as that credential and reports what it turned out to be, measured
rather than declared, and says so loudly when it can write.
The shape: schema, then SQL, then the run, then the answer#
Four nodes, and the order is the point. A model cannot write a statement without knowing the columns, and a model cannot answer without the rows.
{ "id": "schema", "type": "database", "from": "northwind", "mode": "schema", "output": "northwind_schema" }
{ "id": "write_sql", "type": "llm", "output": "northwind_sql",
"prompt": "Write one read-only SQL statement answering the question.\nThe tables:\n{{northwind_schema}}\n\nQuestion: {{question}}\n\nReply with the statement and nothing else." }
{ "id": "run", "type": "database", "from": "northwind", "sql": "{{northwind_sql}}", "most": 50, "output": "northwind_rows" }
{ "id": "answer", "type": "llm", "output": "answer",
"prompt": "Answer only from these rows, and say plainly if there are none:\n{{northwind_rows}}\n\nQuestion: {{question}}" }The model that writes the SQL is in the flow. That is the arrangement the
whole feature is built around: the driver and the credential live in the SQL
Connection Manager, one process per installation, and the statement is
written where every other model call in a flow is written. A deployment's
.env holds an address, not a password, and a leaked folder is no longer
every database any agent ever queried.
Separate nodes on purpose, as knowledge and its answering model are: a
poor answer is traceable to the statement, to the rows or to the answering,
and one node that did all three could only be traced to all three at once.
mode: "schema" - the tables the diagram offers#
{ "id": "schema", "type": "database", "from": "northwind", "mode": "schema", "output": "northwind_schema" }Writes { engine, connection, dialect, dialectUnknown, tables: [{ schema, table, columns: [{ name, type, nullable, shownAs }] }], relationships },
and nothingFound with a say when the credential can see nothing at all.
dialect is a short list of sentences about writing SQL for this engine —
how a table is named, how rows are limited, how identifiers fold. It is an
answer and never something a flow declares, like the engine it follows from,
and it is there because the engine's name on its own left every rule it
implies to whatever the model happened to know: a model wrote a three-part
database.schema.table, Postgres refused it, and the person got an error
code and no answer. Hand the schema to the model whole and these arrive
with it.
dialectUnknown says which of two empty lists this is, and it is always
there. false means the connection manager said it can send these rules and
sent none, so nobody has written any for this engine. true means the manager
never said - it is older than the field - and then nothing at all is known
about this engine's rules, and a sentence in dialectSay tells the model so
in as many words.
The distinction is not theoretical. The sentence that used to be here said an
empty list *says nobody has said anything rather than that there is nothing to
say*, and a manager that had been running since 65 minutes before the field
existed answered exactly that empty list: the three rules were on disk, did
not travel, a model wrote postgres.public.products, and the person got an
error code and a null. Restarting the manager restored all three. So the
manager names the fields it knows how to send and this reads that list -
named, never inferred from a version number, which is the same rule the
monitoring protocol's capabilities keep in the other direction.
relationships is what joins to what:
[{ from: { schema, table, column }, to: { … }, origin }], and an empty list
when the database declares none and nobody has said otherwise.
- It is the connection's saved diagram, not a read of the database - 0.44, and it is the one sentence here worth reading twice. The deployment draws a diagram on the connection and from then on that is the world: a field unchecked there is not in this answer, a table removed there is not in it either, and the live structure is read only to build the first diagram and to say later what has drifted from it. There is nowhere in the DSL to influence any of that, exactly as there is nowhere to put an address - which is also what stops a flow deciding what it may see.
shownAsappears on a column the deployment has given a reader-facing name, and is absent rather than null on one that has none. It is the word to put in front of a person;nameis the word to put in a statement. A model handed only the second writes a correct query that answers in somebody else's vocabulary, and a model handed only the first writes a statement the database refuses.- Ask for it; do not write it down. A schema in a file is stale the
moment the deployment changes what it offers, and nothing says so - which
makes it worse than no schema at all, because it is a confident wrong
answer. This is why a flow that used to
reada schema file stops needing to. - The engine travels with it.
ROWNUMis notLIMIT, and the model about to write the statement is the one that needs to know which. The dialect is an answer here and never something the flow declared. - Every relationship says where it came from, and that is the point of
the field rather than decoration on it.
declaredis a foreign key the database itself has;personwas set by somebody who knows this data;detectedis a guess from matching column names and may be wrong. Most databases a deployment is pointed at were built by somebody else, so the guesses are the common case - and a guess that travels without the word on it is the mistake this platform has made more often than any other: nobody has decided this read as this is so. - Where the relationships come from is the deployment's, exactly as the address and the credential are. They are kept with the connection, and the Diagram button on that connection's row in the Monitor under Settings → Databases is where somebody confirms a guess, draws a join nothing could find, or refuses one. There is nowhere in the DSL to put one, which is also what stops a flow deciding what it may join.
droppedRelationshipsappears only when a relationship points at a column the diagram does not offer - because somebody removed its table, or because the column has gone from the database since the diagram was drawn. The decisions were stored separately, so the two can disagree, and a line against a column that is not there must not read as a relationship that holds. It names which column and which world it is missing from, so somebody is sent to the right screen rather than to the database.- It takes no
sqland nomost, and both are refused by name: a schema request is not a list of rows, and two intentions in one node means the one that wins is whichever the engine reads first.
mode: "sql" - one read, and what comes back#
{ "id": "run", "type": "database", "from": "northwind",
"sql": "{{northwind_sql}}", "most": 50, "output": "northwind_rows" }Absent mode means "sql", as a missing method on an index node means
"fixed". sql is almost always {{a_channel}} - the statement the model
above it wrote - and the channels it reads are checked when the flow is
checked, so a renamed one is caught there rather than arriving at the manager
as an empty string.
Writes { rows, of, cut, nothingFound, dropped, renamed }, and say as well
when there were none or when a column was not shown — or, from 0.48, when
the connection refused the statement, which writes { refused, gate } in
place of nothingFound. most is 1 to 200,
two hundred when it does not say - the same cap the manager keeps, so that a
flow asking for more is refused at design time rather than cut in silence.
ofandcutare not decoration. A truncated answer that reads as a short one is a wrong answer: a model told four rows will say four, and a person will act on four.ofis how many there really were, andcutis true if the manager shortened it or this node did. Tell the model answering to pass that on.- Nothing found is not an error.
rowsis empty,nothingFoundis true, andsaycarries the sentence to give the person - the same lesson theknowledgenode learned late: told only that a list is empty, a model writes there do not appear to be any, but you may wish to check. - A refusal is not a finding of absence — 0.48. The connection's two
gates stop a statement that writes, or that reads a relation its diagram
does not offer. That is a decision somebody made on a screen, not a
fault and not an empty table, so it arrives as an answer:
rowsis empty,refusedis the reason written for a person and naming the relation,gateis"read-only"or"diagram", andsaycarries the longer form to the model, ending ask for the schema and *do not try the same statement again*. Printrefused, neversay—sayis written to instruct a model, and a person shown one reads an instruction that was never meant for them. The run ends done.nothingFoundis absent, deliberately:truewould claim a search came back empty andfalsewould claim it found something, while no search ran at all — and a flow givennothingFound: trueover a refusal tells the person the database was asked and returned no rows, which is the first thing one did. Tell the model answering to say what is not offered, and not to rewrite the statement. droppedandrenamedare what the connection did to the columns, and neither is a decision of the flow's. The deployment's diagram decides which fields an agent is offered and under which name: a field the operator unchecked is in the diagram and not in these rows, and a field with an alias comes back under it. The statement is never rewritten, so an explicitASof the model's own wins. Both are empty in the ordinary case, and empty rather than absent on a manager too old to say - a channel whose shape changes is one a prompt cannot be written against.- A dropped column comes with words.
saynames it, says the statement ran as written, and says not to report the column as missing from the database - which is the wrong conclusion a model otherwise reaches, and it is a sentence about somebody's data in answer to a question about their diagram. Tell the model answering to pass it on. The journal carriesdroppedColumnsfor the same reason, so that a short answer has its reason beside it rather than nowhere.
Read-only is enforced on the statement, in the manager#
Only SELECT, WITH and SHOW are passed, one statement at a time,
before the credential is touched - an allow-list of openers with a scan
for writes hiding behind one, so WITH x AS (...) UPDATE is refused too. A
refusal that arrived after the database had run the statement would not be a
refusal.
EXPLAIN, DESCRIBE and DESC are refused although they read: the
manager checks a statement against the connection's diagram by asking the
database to explain it, and those three cannot be explained. The refusal says
so, so a model is told to ask the question rather than its plan.
That gate is in the one process holding the credential, which is what makes it enforceable once rather than in every generated project. It catches whatever arrives, whoever wrote it - so a statement in a flow is a statement in front of the gate, not past it.
And a statement may only read the tables the deployment offers#
The deployment's diagram says which tables a connection's agents are given, and from 0.46 it says which they may read: the manager asks the database for the statement's plan before running it, and refuses one that reads a relation the diagram does not hold - including through a view, which is the case no reading of the SQL would ever catch. The refusal names the relation and says *through a view, if the name is not in the statement itself*, because the usual case is a relation whose name the flow's model never wrote.
Nothing in the flow decides this and nothing in the flow can see it: which tables a connection offers is the deployment's, exactly as where the connection lives is. What it means for a flow is that a model answering must be told it may be refused and what to do about it - ask for the schema, which is the list of what this connection does offer. A refused statement is a thrown call and not an empty answer, so a flow that catches nothing stops on it.
A statement filtering on an unchecked field is answered, with the field's
values dropped on the way out - it is the table that is a boundary, not the
field. database/README.md has the gate's cost, measured, and the two reads
it cannot cover.
The flow is therefore free to carry one, and ask is refused by name on
a database node: it was this node's field for the length of one afternoon,
on the mistaken claim that the manager wrote the SQL, and a node carrying a
question nothing sends is a node whose author believes the question was
asked.
What the node sent goes to the journal, never to a channel. It is what
somebody debugging a wrong answer needs - *the model wrote country = 'DE'
and the column holds Germany* is a sentence that fixes a flow. Handed back
down the channel it would be the next model's to edit, and then what decides
the query is a model reading its own earlier attempt rather than the schema.
It is written as a tool call, <connection>.ask for a statement and
<connection>.schema for the tables, so a board's run view, the chat page's
History and the studio's Ask bar all draw it the way they draw any other
call - with the statement under it, and the rows it returned. What leaves for
a board is the line and how long it took, and never the statement itself:
that is withoutPayloads, which every payload obeys.
And never a credential#
user, username, password and credential are refused on the
declaration, by name. A flow is a file that travels - into a repository, into
a solution package, onto whatever reads it next - and this is the one
declaration where an address and a secret are both in easy reach. describe
is checked for the same thing: a sentence saying where it is, rather than
what it holds, is refused.
topic: conversation#
"kind"-"user", or"system"for a flow something reads rather than demonstrates: the reference's own worked flows, the test writer LGStudio runs. A system flow has no delete in the Flows list and the studio's server refuses to delete one, because losing it takes a feature away rather than a demonstration. Saying nothing meansuser, which is every flow written so far. It is not versioned and changes nothing about how the flow runs - a flow at 0.1 may say it - but a word that is neither is refused, because a typo would read as "not protected"."conversation": true(0.9) - the flow remembers its conversation. LGFlow keeps ahistorychannel of{ at, asked, answered }, one entry per finished turn, and prompts read it as{{history}}- so "them", "do it again" and "the second one" are understood. New flows are conversational; turn it off only for a one-shot flow that must not remember. The last 20 turns, the oldest dropped - the number isKEPT_TURNS, and before 0.36 there was no limit at all, so turn 30 of a thread was shown 29 turns. A turn already in the channel is never added twice, which is what lets a restarted agent be handed its conversation back: the deployment's state store has the thread and the conversation log has the turn the store was still holding in memory, and both write the sameatfor the same turn.remembers: trueon a channel keeps it into the next message; everything else starts each message from its default."suggestions"(0.10) - up to 8 questions a person would really type, shown as chips under the chat box; a click fills the box. Give 3 to 5, concrete and varied: "Where is ALFKI based?", not "Ask a question".
topic: contract#
From 0.7 a flow says what it is, because its code is generated from what it says:
description- what the whole flow does, start to finish.inputs- the channels a run starts from; the chat shows a box for the first string one and extra fields for the rest.outputs- what to show at the end. A flow with"inputs": []starts by itself when the chat opens (and after New chat) - no Start button - so its first question or card appears at once.requires.env- environment variables the code reads, likeOPENAI_API_KEY. A Google access token declared here - anyGOOGLE_…ACCESS_TOKEN, such asGOOGLE_API_ACCESS_TOKEN- renews itself when the project's .env also hasGOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRETandGOOGLE_REFRESH_TOKEN: LGFlow gets a fresh one before a tool call needs it and again after a 401. The tools just read the variable; never write refresh code into them.requires.services-{ "stripe": "What it is and exactly how to call it" }, shared objects the code reaches asservices.<name>(seeservices). For an MCP server, userequires.mcpinstead - it is structure rather than prose, and the platform can read it.requires.mcp- from 0.23, the MCP servers this flow reaches and which of their tools it may call:{ "crm": { "describe": "...", "tools": [...] } }. Declaring a tool here declares it; it does not also go intools. The flow says nothing about where the server is - no transport, no command, no URL, no credential - because that changes per install and the flow does not (seeservices).requires.settings-{ "maxResults": { "default": 5, "description": "..." } }, values the code reads assettings.<name>. A generated project takes each one from the environment when it is set there -maxResultsfromSETTING_MAX_RESULTS, listed in .env.example with its description - so a deployment changes a setting without editing generated code.requires.packages(0.9) - npm packages the generated code imports, with a version range:{ "@modelcontextprotocol/sdk": "^1.30.0" }. Nothing else may be imported.requires.users(0.22) -true, and nothing else. This flow answers only people it can name: the chat asks who they are before anything else runs, and refuses anybody the board does not know.
There is no list here, deliberately. Who the people are is the
deployment's business, the same split as requires.env: the flow says what
it needs, the deployment says where it comes from. Names written into a flow
would travel into every copy of it and be wrong in all of them by Thursday.
Asked to restrict a flow to particular people, write requires.users: true
and tell them where the names go — in the reply, not in the file. The names
they said must not appear anywhere in the flow: not in requires, not in a
channel, not in a prompt, and not in a description explaining that the flow
does not list names. That last one is the mistake worth naming, because it
is made by understanding the rule: *"the board controls which people (for
example: Shmuel and Dana) may run it"* is a flow carrying two people's names
into every copy of itself, written by a model that had just got everything
else right.
The board narrows it, per agent: its drawer has a Who tab with anybody on this board or only the people ticked below. One flow deployed twice — dev and production, two departments — is two lists, which is the whole reason they are not in the flow.
The agent asks the monitor it reports to, over the link it already has - so
a deployment of this flow needs MONITOR_URL, and the people have to be on
that board. Without one the agent refuses to serve rather than letting
everybody in: a flow that asked for identified users and then answered
anybody would be the worst failure available here. It says so at startup
too, beside the missing keys, rather than leaving the first person to be
turned away to work it out.
How somebody proves it is the board's to decide and the chat's to draw: a password form, and where that board signs people in through Microsoft, a button beside it that goes to Microsoft and comes back to this chat. The flow says nothing about either.
Being identified belongs to the person, not to one conversation: signing
in once opens every chat they start, and twenty minutes of silence locks it
again. Every run is then recorded under the address the board gave back, which
is what a board shows instead of a count of anonymous runs.
docs/guides/monitoring.md has the whole door.
A flow is ready to generate when every handler node, route and tool is
described, every channel is typed, and inputs and outputs are declared.
check_readiness lists what is missing. It also stops a flow whose code calls services.x without
declaring x, and one that talks to an MCP server without its package; and it
warns when a tool that writes (add, place, create, delete, send, pay...) runs
with no approval.
topic: services#
A service is something several tools share - a database connection, an MCP
client. Declare it in requires.services with a description exact enough to
write it from, and let each tool call it (services.mcpDb.callTool(...)). A tool
that calls services.x needs x declared - in LGStudio, describe_flow_contract
with services.
MCP servers: requires.mcp, from 0.23#
Do not describe an MCP server in requires.services. From 0.23 it is
declared as structure, and the whole of what a flow says about one is its name
and which of its tools this flow may call:
"requires": {
"mcp": {
"crm": {
"describe": "The customer system. Read-only except for openTicket.",
"tools": [
"findCustomer",
{ "name": "openTicket", "remote": "create_ticket", "describe": "Raises a support ticket.",
"inputs": { "about": { "type": "string", "required": true, "label": "What is wrong" } } }
]
}
}
}- The tool list is the permission. A server exposing twenty tools gives this
flow only the ones listed. A
toolnode names one of them; declaring it here declares it, so it does not also go in the flow'stoolslist. inputs(0.24) is what the tool takes, in the same shape a tool in the flow'stoolslist uses (seeforms) - and it does the same thing: a call missing a required one stops the run with a form asking for it, filled in with what is already known, and nothing is called until it passes. Without it a model with no way to ask writes a paragraph listing the fields instead, which is not the same thing at all.remoteis the name that tool has on the server, when it differs from the name this flow wants to use. Two servers that both exposesearchare told apart this way; a flow's tool names are flat and must be unique.- Nothing here says where the server is. No transport, no command, no URL,
no token. That is the deployment's business - the same split as
requires.env- and a flow that named a machine would stop working the first time it was installed somewhere else.
Including in describe, which is checked from 0.25: a web address, a
drive letter or a path at one of the usual roots is refused there, with
INVALID_DESCRIPTION. Those keys were refused from the first day and
describe was free text beside them, which made it the way round the rule
without anybody meaning anything by it - *"Local MCP server at
C:\Dev\Apps\mcpDb exposing customer tools"* is one developer's machine
travelling into every copy of the flow. Say what the server is for.
- A server may be local or remote. Which it is, and how it is reached, is configured per deployment; the generated code handles both. Do not assume stdio, and never invent an executable or a command line.
- You do not need to declare
@modelcontextprotocol/sdkinrequires.packagesfor this - a flow that declaresrequires.mcpgets the client it needs. - What a flow declares is a ceiling, not a guarantee. From monitoring 0.8 a board switches each of these tools on or off per deployed agent, while it runs. So declare everything the flow may ever need - that part is yours - and do not promise in a prompt that a particular tool will work. A call the board has not switched on is refused before it goes out, and the node says so. Nothing about this changes what you write here: the switch can only ever narrow the list below, never add to it.
A service is still the right thing for everything that is not MCP - a
database connection, a payment system, a mail sender - because there is no
protocol there to be structured about.
topic: files#
A picture, a recording or a document is a channel of type file (0.9), never a
string of a URL. It holds { id, name, mediaType, url, bytes }; the chat offers
a drop/paste/browse box for it and shows what comes back. Generated code uses
services.files, always there:
await services.files.read(ref)- the bytes, as a Buffer;await services.files.base64(ref)- the same, as base64;await services.files.save({ name, mediaType, data })- returns the reference to write to a file channel (data: a Buffer, base64, or a data: URL).
A tool that produces a file saves it and returns the saved reference, which goes straight into a file channel - no intermediate url channel, no second node.
topic: recipes#
A tool that calls a web API must say the provider, the endpoint, the whole
request body, and the environment variable holding the key (then list it in
requires.env). Prefer a provider the person already has a key for. The OpenAI
calls, exactly:
- Draw a picture: POST
https://api.openai.com/v1/images/generations, body{ "model": "gpt-image-1", "prompt": <text>, "size": "1024x1024" }and nothing else. The reply has no url: the image is base64 atdata[0].b64_json. Save it withservices.files.save({ name, mediaType: "image/png", data })and return what save gives. Never add response_format. - Look at a picture, or ask in words: POST
https://api.openai.com/v1/responses, body{ "model": "gpt-5-mini", "input": [{ "role": "user", "content": [ { "type": "input_text", "text": <the ask> }, { "type": "input_image", "image_url": "data:<mediaType>;base64,<services.files.base64(file)>" }] }] }. The answer is the text of every content item of type "output_text" acrossreply.output[], joined - over HTTP there is nooutput_textfield. Leave out input_image when there is no picture. - Search the web: the same /v1/responses call with
"tools": [{ "type": "web_search" }]and the query as the text.
topic: subflows#
A node that runs another flow (0.13), which from 0.14 may talk to the person itself:
{ "id": "find_customer", "type": "flow", "flow": "./flows/customer-lookup.json",
"input": { "customerId": "{{customer_id}}", "asked": "{{question}}" },
"output": { "customer": "found_customer", "orders": "their_orders" } }The child is an ordinary flow file, with its own description, inputs,
outputs, channels, nodes, tools, routing and limits - the contract it has
declared since 0.7 is exactly the call signature this node uses.
- Nothing is shared but the mapping.
inputfills the child's inputs from the parent's state (each value a template, as in a prompt or a tool argument);outputsays where each of the child's outputs lands in the parent. The child cannot see the parent's other channels and cannot write them. - The address is relative to the file naming it, and reading a flow reads the flows it runs. Every input the child declares must be given, and every name mapped must be one the child declares - a typo is an error, not a silently empty channel. An input named with nothing in it counts as not given; an output mapped to nothing is a choice, and that answer is dropped.
- A child may stop for a person (0.14). An
ask, aform, anapprovalor a tool short of a required input inside a flow run as a step puts its question to whoever the flow on top is talking to. The run waits and resumes exactly as it does for the flow on top - the child waits in the same checkpointer, and answering carries on inside the child, not from the start of the step. The question names the step that asked it, the way a step is named:"node": "find_customer/ask_which". Below 0.14 it is refused instead, so a flow declaring an older version is told to raise it. Put the question where the work is: a flow that looks an order up and needs to know which order should ask for it itself, rather than making every flow that runs it ask first and pass the answer in. - A flow cannot run itself, directly or in a circle.
- Steps of a child are reported as
<node>/<their node>-find_customer/look_up- in the trace, the steps panel and the journal, so it is always clear which flow a step belongs to. - One project is generated from the whole tree. Deploy writes each child
beside the parent at the address its node gives, and its code under
src/flows/<its file>/. A handler or router is the child's own; a tool or a service is the project's, one of each name, so two flows in the tree must not describe the same tool or service differently - readiness refuses that.
Use it when a job stands on its own and more than one agent wants it: a customer lookup, an order search, a document answerer, a send-email step. A flow of six nodes does not need splitting; a folder of agents that each need the same lookup does.
topic: patterns#
- Budget or quota: the running total is a channel with
remembers: true. The check comes BEFORE the node that spends - a gate node with a conditional edge: "ok" to the work, "exceeded" to a node that says so, or to an approval asking to go over. Checking afterwards has already spent the money. - Model decides, code acts: an llm node writes a JSON plan; a handler reads it and builds calls or ids; a conditional edge routes on what the plan wants.
- Lookup, then answer: tool node (onError to the answer) -> llm node that answers from the facts and says plainly when they do not answer the question.
- Write with a person in the loop: handler builds a queue of calls -> tools node with inputs (forms) and an editable approval -> llm node reporting each result or error. Never report a write that has no result.
- Agent loop - tools until a final answer: when the model should call
tools as many times as it needs, loop: an llm node decides the next step as
JSON (
{ "calls": [...] }or{ "answer": "..." }) reading{{history}}, the question and the results so far (an append channel); a conditional edge from a handler that reads it: route "call" -> a tools node, route "done" -> the answer; the tools node goes back to the llm node. Put a limit on the loop route -"limits": { "call": { "max": 6, "then": "done" } }- so it cannot run for ever. Thetask-agentexample flow is this shape end to end, with reads and writes in separate tools nodes. Prefer it to one plan made up front whenever a request may hold several jobs, or needs what an earlier call returned: a single plan quietly drops the job it cannot finish. - Reads and writes apart: an approval stops everything on its node, so lookups and writes go through different nodes - reads in a tools node with no approval, writes (addOrder, addNewCustomer...) in their own tools node with an editable approval. Route to one or the other by what the call does.
- Many items at once: fanout over the list, target writes an append channel, a gather node after it; route around the fanout when the list may be empty.
- Open with a form: a flow that needs particular details to begin takes no
inputs and starts at a form node - the chat opens with the fields rather than
a message box. See
forms. - A job more than one agent does: its own flow file, run with a
flownode - the lookup, the search, the document answerer. Seesubflows. - Answer from a document: see
read.
topic: views#
How the chat shows the agent's data while it runs (0.11). A top-level list,
beside state and nodes:
"views": [
{ "id": "scoreboard", "type": "card", "place": "pinned", "title": "Your score",
"items": [{ "label": "Points", "value": "{{score}}" },
{ "label": "Word", "value": "{{current_index}} / {{round_words.length}}" }],
"progress": { "value": "{{current_index}}", "max": "{{round_words.length}}" } },
{ "id": "picture", "type": "image", "place": { "after": "get_image" },
"file": "{{current_image}}", "caption": "Word {{current_index}}" }
]View types (type):
| type | shows | takes |
|---|---|---|
card | a title, a line of text, label/value items and a progress bar - any of them | title, text, items, progress |
image | a picture from a file channel | file (one template), caption |
action | a button under a question; pressing it answers the question | label, sends, icon |
Every view has an id (a name, unique among the views), a type and a
place, and may have a description. Nothing else is accepted.
A card is for a glance, never for the data. An item whose value is one
whole list or object channel - "{{stage1_results}}" - prints that channel
as JSON in the middle of the conversation, which is the wall of text it looks
like. Show something small: {{results.length}}, one field, or a channel a
node writes in words. The answer itself is where the data belongs - as a table
when it lists several things (topic llm).
Everywhere else, the shape decides (0.17). Wherever a value reaches a
person without a view saying how - what an ask node shows, what an approval
card shows - it is drawn by what it is, and no flow has to ask for it:
| the value | drawn as |
|---|---|
| several records sharing fields | a table, one row each |
| one record with named fields | a form with nothing to fill in, labels and all |
| a record whose values are lists of records | one table per key, under that key |
| a value with lines of its own | a block, under its name, keeping the lines |
| anything else | text, as before |
A table, a block of text and a record inside a record each take the whole width
with their name on the line above; only short values sit beside their labels. A
key that is already a sentence is shown as somebody wrote it - customer_id
becomes "Customer id", "All customers from the UK" is left alone. So a flow that
answers two questions at once writes one entry per question and the chat shows
each with its own table, rather than one table with a column saying which
question a row came from.
The answer itself is drawn this way too, and a string channel is no
obstacle: an answer that is wholly one JSON record, or one JSON list of them,
is read back as what it is. So a prompt that says "answer with the records and
nothing else" gets a form or a table, while a prompt that answers in words is
left in words. Prose with a brace in it stays prose.
customerId is labelled "Customer id", an empty field reads as — rather than
a gap, and a record inside a record is drawn the same way. So a prompt does not
need to ask for a Markdown table to get one, and a flow does not need a view
per channel to avoid printing JSON at somebody.
Buttons are actions. An action goes { "with": "<ask node id>" } and is
drawn inside that question - under the picture and words it shows. Pressing it
answers the question with sends, exactly as if the person had typed it, so
the flow's routes decide what it means:
{ "id": "hint_button", "type": "action", "place": { "with": "ask_user" },
"icon": "💡", "label": "רמז", "sends": "רמז" }A router after the ask node sees sends in the node's output channel, like
any answer - route on it ("the reply asks for a hint") the way you would route
on typed words. Label and sends are fixed text (no templates); at most 4
actions go with one question, and only with an ask node. The person can always
type instead. A press is not shown as a message: the pressed button stays
highlighted and its sends text goes to the flow only - so sends may be a plain
keyword ("hint", "next") that the person never sees.
place:"pinned"keeps it above the conversation and redraws it whenever what it shows changes - a scoreboard, a progress bar.{ "after": "<node id>" }drops it into the reply each time that node finishes - a summary card after each word, a picture after it is fetched. After an ask node means after it is answered, not beside the question; what goes beside a question is itsshow, and its buttons are actions.- Templates fill every text:
{{channel}}and paths, as in prompts -{{round_words.length}}counts a list. Every channel read must be declared. items: 1 to 12{ "label", "value" }; the value is usually a template.progress:{ "value", "max", "label"? }, numbers or templates that give numbers; the bar is left off while they do not.- A view appears only once every channel it reads has a value - no "0 / " card before the lesson has started. A pinned view whose channels are emptied again is taken away.
A view only displays: it never writes state and never decides anything, so adding or removing one never changes what the agent does. The server fills views in from the real state and sends the page the finished card - never the state itself. A run with no chat page (a terminal, a test) ignores them.
Use a view for what the person should see while the agent runs; use outputs
for the answer at the end, and an ask node's show for what a question is
about. Never add a node that only exists to show something.
- Only an action is a button. Never put a "button" in a card's items or text, or in a channel for the page to draw - it would only look like one, and nothing on the page reads such a channel. Never describe what the page "must" do: it draws cards, pictures and actions, and nothing else.
- An ask node's
showalready puts its channels beside the question: do not add a view that shows the same thing again. - Picture size. The page draws a picture at the size of its file, up to the width of the conversation and 60% of the window's height. A flow cannot set how big it is drawn - but it decides how big the file is. For smaller pictures, change the description of the node that saves them so it saves smaller files: from Wikipedia, ask for a 250, 330 or 500 px thumbnail (other widths come back at the next stored size up - 512 arrives as 960).
- Views are added, changed and removed one at a time. A request about one view leaves the others exactly as they are - their wording, language and order were somebody's choice.
Moving something out of the reply into a view - "show the hint on a card,
not in the message": the text is in the reply because a node writes it into a
channel the question or answer reads ({{feedback}}, say). Two changes, both
needed:
- Give it a channel of its own, written by that node, and add a view that reads it.
- Change that node's
descriptionso it no longer puts the text into the reply channel - say what the reply says instead ("tells the person to look at the hint card"). The node's code is rewritten from the new description at Deploy.
A pinned view stays until what it reads is emptied: say which node clears the
channel (writes ""), for example when the next word starts. And a view reads
the state as it stands between steps, so it should read the finished value - if
one node drafts a text and another cleans it up, give the draft its own channel
and let the view read only the clean one.
topic: page#
How the agent's chat page looks (0.12) - its type, colours, which buttons it
shows. A flow's page section, beside state and nodes; it changes nothing
the agent does, and only this agent's page. What goes around the
conversation - a sidebar, a brand mark, a strip of figures above it - is
shell (topic shell), not css here:
"page": {
"question": { "size": "large", "bold": true, "color": "#1f4fbf" },
"fontScale": 1.2,
"font": "rounded",
"accent": "#2f6fd0",
"background": "#fdfbf6",
"direction": "rtl",
"width": "wide",
"title": "שיעור אנגלית",
"welcome": "ברוכים הבאים לשיעור!",
"hide": ["history", "suggestions"],
"css": ".bubble { border-radius: 22px; }"
}| setting | what it does | values |
|---|---|---|
question | how the agent's questions read | size: normal, large, x-large; bold: true/false; color: #rrggbb |
fontScale | all the text, bigger or smaller | 0.8 to 1.6 (1 is as it is) |
font | the typeface | default, rounded, serif, mono |
accent | buttons, links, highlights | #rgb or #rrggbb |
background | the page behind the conversation | #rgb or #rrggbb |
direction | which way text runs | auto (each message decides), rtl, ltr |
width | how wide the conversation runs | normal, wide |
title | the name in the header and the tab | text, at most 80 characters |
welcome | the words before the first message | text, at most 400 characters |
hide | parts of the page to leave out | history, newChat, about, suggestions, hint |
layout | how the page is divided and where its parts sit (0.26) | classic, messenger, column |
logo | show the deployment's own logo in the bar (0.26) | true/false |
who | show the signed-in person's name in the bar (0.26) | true/false |
colors | each part of the page, its own two colours (0.26) | see below |
css | anything the settings do not cover | a style sheet, at most 20000 characters |
layout (0.26)#
Everything else in page says how the chat looks. layout says how it is
divided - what sits where. Only the page's own parts move; nothing is
added.
| layout | the shape it gives the page |
|---|---|
classic | a bar across the top, the conversation down the middle, the box fixed at the bottom with the suggestions under it |
messenger | the name over its description in a slim bar, a wider conversation, and the box floating above the bottom edge with the suggestions over it |
column | no bar: the name and the description print at the top of the page and scroll away like a document, the two buttons go to the corner, the box is a narrow one at the foot of the column |
logo and who (0.26)#
"page": { "logo": true, "who": true }logo is true, never a picture and never an address: the flow says this
agent has a logo, and the deployment puts the file in its own public/
folder named logo - .svg, .png, .webp, .jpg or .gif. The page asks
this agent for it and nowhere else. A flow carrying the image would be a text
document with kilobytes of base64 in it; a flow carrying an address would be a
page calling a host nobody controls, once per conversation, which is the rule
page.css already lives under.
who shows the signed-in person's name in the bar. It has something to show
only on a flow with requires.users (topic contract); on a flow without
one it is simply never filled in, rather than refused, because a flow may be
given users later.
colors (0.26)#
accent and background are two strokes across a whole page. colors is the
parts of it, named for what somebody looking at the page sees:
"colors": {
"header": { "background": "#111827", "text": "#f9fafb" },
"rule": "#1f2937",
"desk": { "background": "#0b0f14", "text": "#e5e7eb" },
"welcome": { "title": "#f9fafb", "text": "#9aa4b2" },
"composer": { "background": "#111827", "text": "#f9fafb" },
"footer": { "background": "#0b0f14", "text": "#9aa4b2" },
"suggestions": { "background": "#111827", "text": "#93c5fd" }
}| part | what it is | takes |
|---|---|---|
header | the bar across the top: the name, the description, History and New chat | background, text |
rule | the hairlines between the bar, the conversation and the composer | one colour |
desk | the surface the conversation sits on, and the words on it | background, text |
welcome | the first screen: the agent's name in large type, and the words under it | title, text |
composer | the box the person types in | background, text |
footer | the strip the box and the chips sit in | background, text |
suggestions | the chips under the box | background, text |
Every value is #rgb or #rrggbb, and a part or a key nobody knows is
refused rather than half applied. A part colours what is inside it: giving the
header a text colours the name, the description and both buttons, with the
description a little quieter so it is still the description.
Set a dark page here, not with background alone. background is the
paper and nothing else: a page whose paper is dark still has a pale bar across
the top and pale chips along the bottom, because every other colour on the
page is its own. colors.desk is the same paper with its text beside it, and
the other parts are the rest of the page.
Use the settings first; they are checked, and a mistyped one is refused rather
than half applied. css is for the rest, and comes last, so it wins. It
styles the page's own parts - .bubble (a message), .msg.user .bubble (the
person's), .question-text (a question's words), .question-action (a
button under it), .view-card (a card), .answer-table (a table in an answer), header, .composer (the box to type
in) - and the page's colours are variables it may set: --accent, --paper,
--panel, --ink, --text (the base size). It cannot load anything from
elsewhere - url(), image-set() and @import are refused, data:
included, so no fonts and no pictures - and the worst it can do is therefore
look wrong. Gradients, radii, shadows and letter-spacing are all fine.
"Bigger, bold questions" is question, not css; a rounder, warmer look
is font and accent; only what no setting says belongs in css.
topic: shell#
The page the chat sits inside (0.25) - a brand mark, a column each side, a
strip above, a line below. page says how the conversation looks; this says
what is around it. A flow's shell section, beside page:
"shell": {
"brand": { "initials": "SD", "name": "Support Desk", "note": "AI Support Platform", "color": "#4f46e5" },
"aside": [
{ "type": "nav", "title": "Workspace", "items": [{ "label": "AI Assistant", "on": true }, "My Tickets"] },
{ "type": "who", "label": "signed in" }
],
"rail": [
{ "type": "status", "label": "Available", "state": "ok" },
{ "type": "stat", "label": "Open tickets", "value": "" },
{ "type": "links", "title": "Popular articles", "items": ["Reset your password"] }
],
"below": [{ "type": "note", "text": "Answers may be wrong." }]
}A shell is scenery. Every value in it is text you write. Nothing in a shell
reads a channel and nothing in it is live - "{{revenue}}" in a stat is
the characters, not the number. What shows the agent's own data while it runs
is views (topic views), which already knows how to draw a channel.
So a stat you cannot fill in is a stat you leave out. A figure typed
here stays whatever you typed, and a page of numbers that never change reads as
a report and is not one.
| region | where it goes |
|---|---|
brand | the top of the sidebar: initials (1-3 characters), name, note, color |
aside | a column before the conversation |
rail | a column after it |
above | a strip between the header and the conversation, full width |
below | a line under the box you type in |
A region left out is not drawn, and a flow with no shell is the page exactly
as it was. On a narrow screen the columns go under the conversation.
Blocks (type):
| type | shows | takes |
|---|---|---|
nav | a list of labels, one of them current | title, items - each a label or { "label", "on" } |
stat | a figure with its name | label, value, note |
note | a small panel of words | title, text |
links | a list of titles | title, items |
status | a dot and a word | label, state: ok, busy, off |
who | who is signed in | label |
nav and links lead nowhere. They are furniture: nothing happens when
somebody presses one, and a flow cannot make one do anything. Put in them what
the agent can be asked about, not a menu of pages that do not exist.
who is the one block that reads anything, and it reads only what the
page already knows from signing somebody in (requires.users, topic
contract). It draws nothing when nobody is signed in, and a flow cannot put a
name in it.
A shell cannot load anything from elsewhere - no pictures, no addresses - for
the same reason page.css cannot: the worst a page should be able to do is
look wrong. A brand mark is initials and a colour.
topic: agents#
A node that delegates to an agent chosen while the run is happening (0.19):
{ "id": "delegate", "type": "agent",
"agents": ["sql", "documents", "general"],
"agent": "{{decision.agent}}",
"input": "{{decision.input}}",
"output": "agent_result" }An agent is a flow with a contract. Nothing new is declared to be one: the
description, inputs and outputs a flow has declared since 0.7 are the
call signature, and a flow that makes a good agent is one whose contract is
exact. Registering a flow that has no description, or no outputs, is refused -
an orchestrator chooses between agents by what they say they do, and gets back
only what they say they give.
agentsis the permission, and it is required. It lists the names this node may delegate to - names, never addresses. A name outside the list is refused whether or not the project has such an agent, and a node with no list may call nothing. Keep it to what this flow actually needs: a research orchestrator that listspaymentscan be talked into using it.agentsays which one, this time. Usually one template reading the channel a router wrote -"{{decision.agent}}"- and a fixed name is allowed for an agent that never changes (it must still be inagents).inputis what to call it with: one template holding the object a router produced, or a mapping like a flow node's. It is checked against the child's contract before the child is built - every declared input must be there, and anything undeclared is refused by name.outputis one channel, holding what the agent declares it gives back plusagent, the name of whoever answered. One channel rather than a mapping because the flow cannot name another agent's channels when it does not yet know which agent it will be. Nothing else crosses: the child's schema, its queued calls, its rows stay the child's.
Where the agents live is the project's, not the flow's. The flow names capabilities; a registry file beside the project resolves them:
{ "agents": { "sql": { "flow": "./agents/sql/flow.json" },
"documents": { "flow": "./agents/documents/flow.json" } } }Deploy reads that registry and carries what it names: each agent's flow, each
agent's code (under src/agents/<name>/, keyed <node>/<agent>.<handler>), and
a registry pointing at the copies - so a generated project runs on its own. A
delegating flow with no registry is refused rather than deployed broken.
Loaded with loadAgentRegistry and handed to the compiler as
buildGraph(flow, handlers, { llm, agents }). This is the same split as
requires.env and requires.services: the flow says what it needs, the
deployment says where it is, and the flow stays portable.
Choosing is an ordinary llm node with a schema (0.15). Put the agents'
contracts in a channel, ask for { agent, input }, and let the agent node act
on it - there is no special router kind and no free text to parse:
{ "id": "decide", "type": "llm", "output": "decision",
"prompt": "The desks:\n{{catalogue}}\n\nThe question: {{question}}",
"schema": { "type": "object", "properties": {
"agent": { "type": "string", "description": "sql, documents or general." },
"input": { "type": "object", "description": "What to ask that desk.",
"properties": { "question": { "type": "string", "description": "The question." } },
"required": ["question"] } },
"required": ["agent", "input"] } }flow versus agent. A flow node is a child known when the flow is
written - the address is in the file and it is checked then. An agent node is
a child known only while the run is happening. Same execution underneath; the
difference is when the child is chosen and therefore when it can be checked. Use
flow whenever you know: it is checked earlier and reads plainer.
What is refused, and never silently worked around: a name that is not a name,
a name outside the node's list, a name nothing registered, an input the contract
does not accept, a child that failed, a child that wrote none of its outputs, and
an agent already running above this one. Every one raises LGFlowAgentError with
a code. Nothing ever falls back to a different agent - an answer from the wrong
desk is worse than no answer, because it looks right.
Tracing. Each invocation is traced as an enter/exit pair carrying
invocationId, runId, parentRunId, the agent's name, how long it took and
whether it worked; every step inside carries the child's runId and is named
<node>/<agent>/<their node>. agentTree turns the flat list back into the
tree it was.
topic: identity#
Who a flow is (0.21). Four optional keys at the top of flow.json:
{
"version": "0.21",
"id": "8f14e45f-ceea-467a-9f1a-2b0f8e5c91aa",
"name": "SQL Engine",
"createdBy": "shmuel",
"lastUpdatedBy": "shmuel"
}id is generated once, when the flow is first saved to a file, and never
changes - not when it is renamed, not when it is moved, not when it is deployed.
A draft nobody has written is nobody's business; a file is a thing other things
refer to, and a chat client, a trigger file, a deployment record and a monitor
all address it by this. A saved flow without one cannot be referred to at all,
which shows up as an agent that runs perfectly and can never appear on a board.
Saved somewhere else, a flow gets a new id: a copy is another flow, and two files claiming one id are two agents a monitor can only draw as one row.
name is what people call it, and is not unique. Two flows may share one. It is
set when somebody says what to call the flow and never guessed from a file name;
an unnamed flow is still perfectly addressable, because the id does not wait for
it.
What is deliberately not here: when it was last deployed, and to which
folder. Those belong to the deployment, and the deployment already records both
in its own .lgflow/manifest.json. Putting them in flow.json would mean a
deploy rewrites the flow - which changes the flow's hash, which is the thing the
deploy compares against to decide whether the flow changed. Every deploy would
leave the flow looking like it needed another one.
A flow written before 0.21 has none of these, and a flow saved back to an older version loses them - including its id, because an id kept in a file whose version says it cannot have one is how two flows end up claiming the same id.
topic: triggers#
What starts a flow (0.20). Not flow syntax: triggers live in a file of their
own beside the flow, and flow.json never mentions them.
{
"flow": "./flow.json",
"triggers": [
{ "id": "daily-report", "type": "schedule", "schedule": "0 8 * * *",
"timezone": "Europe/Nicosia",
"input": { "request": "Summarise yesterday's orders" } },
{ "id": "new-order", "type": "event", "event": "order.created",
"input": { "order": "{{event.data}}" } },
{ "id": "support-ticket", "type": "webhook", "path": "/support-ticket",
"method": "POST",
"input": { "request": "{{webhook.body.subject}}" } }
]
}The separation is the point: flow.json is what the agent does, triggers.json
is what causes it to run. A flow stays runnable by hand with no trigger file at
all, which is every flow written before 0.20.
scheduletakes five cron fields - minute hour day-of-month month day-of-week - and an optional IANAtimezone. A star, a number,a-b, a comma list, or any of those stepped with/n; month and weekday also take names. No@daily, noL, no seconds.eventtakes one name -order.created- matched exactly. No patterns: one trigger listens for one name.webhooktakes apathandPOSTorGET. A path, never a URL: where this is deployed is the deployment's business.inputis the same mapping for all three, filled the way a prompt is. One template on its own hands over the value, so"{{event.data}}"passes an object as an object. What there is to read depends on the kind: a schedule hastriggerandnow; an event addsevent.nameandevent.data; a webhook addswebhook.method,webhook.path,webhook.bodyandwebhook.query. Never headers - a mapping that could read one could put a token into a flow's state and from there into a trace.enabled: false leaves a trigger in the file and stops it firing.deliversays where the answer goes, and is what makes a schedule worth having: nobody is sitting in the chat at eight in the morning.
{ "id": "hourly-headlines", "type": "schedule", "schedule": "0 * * * *",
"input": {},
"deliver": { "to": "webhook", "urlFrom": "SLACK_WEBHOOK_URL",
"format": "table",
"body": { "text": "Headlines:\n{{outputs.headlines}}" } } } to is webhook - one target, which covers Slack, Teams, Discord and
anything else that takes a POST. The address is a credential, so it is named
rather than written: urlFrom is the environment variable holding it, and a
URL pasted into url is refused unless it is a localhost one for trying
things out. body is filled by the same mapping input is, and may read
outputs (what the flow produced), trigger and now - nothing else.
format is json (the default) or table, which lays result rows out in
columns, because a chat channel renders mrkdwn and an HTML table arrives
there as literal characters.
What is delivered is data. A chat channel is not a web page: Markdown
table syntax arrives at Slack, Teams and Discord as literal pipes and dashes,
and format: "table" cannot mend that - it lays out records, and a string
handed to it passes straight through untouched. So an output a trigger
delivers is a list of records - a list channel, written by an llm node
with a schema for its fields - and never a prompt that formats a table
itself. The chat still shows a table, because records draw themselves as one
(0.17), and the delivery lays the same records out in columns. Fence the value
in the body, which is what keeps those columns lined up in a proportional
font:
"body": { "text": "Headlines:\n```\n{{outputs.headlines}}\n```" }Not in the flow. A flow has to stay runnable by hand: put "post it to Slack" in the graph and every chat run posts to Slack too, when the person who asked in the chat wants the answer in the chat. Where the answer goes is a property of this automation, exactly as its schedule is. A delivery that fails does not fail the run - the next firing is the retry.
What a payload cannot do. The flow is decided when the file is read, before anything arrives. Nothing from outside can name a flow, a file, a handler, a module or an agent - it reaches the run through the declared inputs of an already-chosen flow and through nothing else.
Tracing. A triggered run traces { type: "triggerId, triggerType, runId,
triggeredAt } around the ordinary run, on the same tracer, so one trace holds
trigger, flow, agent and child flow. triggerTree puts it back together.
Run it: createTriggerRuntime(spec, { run, flow, trace }), then start(),
emit(name, data), receive(request), fire(id) and stop() - which clears
every timer and listener. See the guide triggers.
topic: tests#
flow.json says what the agent is. A *.test.json beside it says what the
agent is expected to do - v0.16. Tests are never inside the flow: one file
describes the thing, the other describes expectations about the thing.
examples/structured-classifier/
flow.json
handlers.js
tests/
routing.test.json{
"flow": "../flow.json",
"tests": [
{ "name": "Billing request routes to the billing answer",
"input": { "question": "Why was my card charged twice?" },
"mock": { "nodes": { "classify": { "category": "billing", "confidence": 0.98 },
"answer_billing": "Send us the dates." } },
"expect": { "state": { "classification.category": "billing" },
"path": ["classify", "answer_billing"],
"output": { "answer": { "notEmpty": true } } } }
]
}Each test in the result carries a run: the path it took, the tools and
models it reached, and events - everything that happened in the order it
happened, which is what a person reads when an assertion alone does not say why.
A model step carries the prompt the node built: a test stands in for every
answer, so nothing calls a model, and this is the only place that prompt is ever
seen - what the templates filled in, what a v0.15 schema added, what the
conversation carried. Long ones are cut, and say how long they really were.
Run them: lgflow test tests/routing.test.json (exit 0 when all pass, 1 when
any fails or a file cannot be read), or runFlowTests(path) from
lgflow/testing, which answers with data rather than printing.
A test file says flow (relative to itself), an optional handlers (the module
the flow's handlers come from - by default handlers.js beside the flow, then
src/registry.js, either exporting createHandlers), and tests. It has no
version of its own: the flow it points at declares the DSL version.
Mocks replace the dependency, not LGFlow. Each one is put exactly where the real thing goes in, so everything downstream of it is the real thing:
mock.nodes- by node id, what a model answered. A string is the model's own words; anything else is the JSON it would have written. A v0.15schemais then checked as it always is, so a mock of the wrong shape fails the node exactly as a real answer of the wrong shape would. There is no test-only way into state. What a model answered, not what state holds afterwards: for a node whose output isplan, that is{ "want": "details" }and never{ "plan": { ... } }, which lands one level down and leaves the flow reading nothing. A mock wrapped in its own output channel is refused. A node on a loop is asked again each time round, and what ends the loop is usually that it answers differently.{ "then": [ ... ] }(0.17) says what it answers the first time, the second time, and so on - a bare list is already a list-shaped answer, so a sequence says so, the same way a failing tool does. Asked once more than the list has answers for, the run stops and says which node and how many times: a quiet repeat is how a loop runs to its route limit and the test reads as if the flow were broken.mock.tools- by tool name, what the call returned. Nothing leaves the machine: a tool with no mock is an error, not a real call.{ "fails": "why" }makes the call go wrong instead, which is the only way to reach what a flow does about a failure - itsretry, itsonError. A node withonErrordoes not fail the run: it carries on at the node named there. Neither does atoolsnode, ever: it runs every call in its queue and writes the error beside the one that went wrong, so a test about that expects"results.0.error", notfails- and one that expectsfailsis refused before it runs. A tools node can call one tool several times in a run, and then{ "then": [ ... ] }says what it answers each time, the same way a model does - any one of them may be{ "fails": ... }, which is how one call goes wrong and the others do not. A bare list is still one result that happens to be a list.mock.reads(0.17) - by node id, the document areadnode got. A read node fetches a PDF, a Word file or a web page, which is the one thing in a flow that reaches outside the machine without being a tool, so it is stood in for the same way. A string is the document's text; an object is the whole document, for a test that cares about its title or its pages; and{ "fails": "why" }is the read going wrong - the path a read node exists to have. A read node with no mock reads for real, as it always did. A read node is not a tool:mock.toolscannot stand in for one, and a tool mock on a flow that declares no tools is refused.mock.answers- by node id, what the person did.true/falsefor an approval (they travel as the words the Approve and Decline buttons send), text for anask, an object for aform. Giving one the wrong kind - a string where a form wants its fields - is refused when the file is read, not left to show up as a run that stopped for a person fifty times. An approval nobody speaks for is approved, so a test about something else is not blocked by a card it never mentioned. A node can stop more than once, and then a list says what happens each time:"add": [{ "customerId": "LGFLW" }, false]fills a form in and then declines. When the list runs out the node is treated as one nobody spoke for. But only atoolsnode with a call missing something stops twice - it completes its calls, forms answered, before it asks whether to make them. A queued call that already has every required argument asks nothing, so that node stops once, for the approval, and one answer is what it wants:"add": true. A list there hands the first entry to the approval, which is not what it is for.
A node that asks a model with no mock is an error too - and when every route through a flow ends at one llm node, a test that expects the run to finish is refused before it runs unless it mocks that node. A test that stands still is worth more than a test that quietly costs money.
Only an llm node has an answer to stand in for. Every other kind runs for
real, which is the point: a handler runs its code, a tool node calls a tool
(stand in for that under mock.tools), and a flow node runs the flow it
names. A mock on one of those is refused, and the refusal says which it is and
what to do instead. A flow with no llm nodes and no tools needs no mock at all
- it is tested by what goes in and what comes out.
A step inside a flow this one runs (0.17) is named <the flow node>/<its own
node> - find_customer/look_up - the same way a trace, the journal, the studio
canvas and a question from a child all name it, and three deep reads a/b/c.
So a test may stand in for a model inside a child and expect a path through one:
{ "name": "The desk reports on a customer the lookup found",
"input": { "customer_id": "ALFKI" },
"mock": { "nodes": { "find_customer/decide": "Alfreds Futterkiste, Berlin" } },
"expect": { "path": ["find_customer/decide", "find_customer", "write_report"] } }The child's step leaves the path before the flow node that ran it does, because it finishes first. The flow node itself still cannot be mocked - it is the flow running, not a model answering.
Where the paths point. flow and handlers are both read from the test
file: "flow": "../flow.json" sits beside "handlers": "../handlers.js". With
no handlers, the code is looked for beside the flow - handlers.js, then
src/registry.js - which is where a flow that lives with its own code keeps it. A
flow kept in a flows/ folder is generated into a project of its own, so its test
file has to say where that is.
Settings (0.17). A flow that cannot run until a setting is set - where to look, which limit to keep to - is set from the test file:
{ "flow": "../flow.json",
"settings": { "flowsRoot": "../.." },
"tests": [ { "name": "...", "settings": { "flowsRoot": "." }, "expect": { ... } } ] }The file speaks for every test in it; a test may set its own, which wins. Only settings the flow declares may be set, and a file that sets one for a flow with none is refused rather than quietly ignored.
What a test may expect, and nothing else - an unknown expectation is refused by name:
| expectation | is |
|---|---|
state | each dotted path and what it must hold: "classification.category": "billing", "results.0.title": "first". null means never written; { "notEmpty": true } means something. There are no other matchers, and an invented one - equals, contains, toEqual - is refused by name |
output | the same, over the flow's declared outputs |
path | the node ids that ran, in order |
toolCalled · toolNotCalled | a tool name, or a list of them |
approvalBefore | that node was put to a person, and did not run before they answered - approved-and-ran and declined-and-never-ran both keep that promise |
fails | true, or the text the failure must contain - for testing what happens when something goes wrong |
path counts a fan-out's copies as one entry: which node ran is deterministic,
how many copies finished first is not. A node that never finished is not in it
at all: one whose approval was declined never ran, and one that failed did not
get to the end - so neither belongs in an expected path.
A file is checked before it runs, against the flow: a mocked node that is not
there, a mocked tool the flow never declares, a path through a node that does
not exist, an approvalBefore on a node with no approval - each is named, and
all of them at once.
topic: evals#
How well does the agent answer? An *.eval.json beside a flow - v0.18. A test
asks whether the flow behaved; an eval asks whether what it did was any good,
and answers with a score and the reason for it rather than a pass or a fail.
examples/answer-desk/
flow.json
tests/answering.test.json what it must do
evals/answers.eval.json how well it does it{
"flow": "../flow.json",
"criteria": {
"correctness": { "description": "The answer agrees with the reference facts.", "weight": 0.6 },
"groundedness": { "description": "It claims nothing the lookup did not find.", "weight": 0.4 }
},
"metrics": { "latencyMs": { "max": 5000 } },
"thresholds": { "overall": 0.85 },
"evals": [
{ "name": "Where a customer is",
"input": { "question": "Where is ALFKI located?" },
"reference": { "facts": ["City: Berlin", "Country: Germany"] } }
]
}See first what it would do, for nothing - each case told as what it does (what is asked, what stands in for the real thing, what is taken as true, what the judge is asked, what must hold whatever the score is) - with lgflow eval evals/answers.eval.json --preview runs every check a run runs, stops before a model is built, and prints the prompt each judge would be asked - LGStudio's Tests panel has a Preview button beside Score for the same thing. Every run writes itself down beside the suite - .lgflow/journal.jsonl for every agent and judge call, .lgflow/runs/ for the whole result - so a low score can be explained afterwards; --no-journal turns that off. Every case also reports any mock the run never used, which is the usual reason a score is nothing like the one expected. Then run them: lgflow eval evals/answers.eval.json (exit 0 unless a threshold was
missed or a case could not be run), runFlowEvals(path, { llm, judge }) from
lgflow/evals, which answers with data, or Score in LGStudio's Tests panel -
which says how many model calls it is about to make and waits for a yes.
An eval file says flow (relative to itself), an optional handlers and
settings exactly as a test file does, and then either evals - an inline list
of cases - or dataset, a path to a file of them. One or the other, never
both: a file that says both is refused rather than merged in an order nobody
could guess. criteria, metrics and thresholds may be said once for the
file; a case may add its own or replace one by name.
A case says name - unique, and what its score is reported under, never its
position - and input, and optionally reference, criteria, metrics,
mock and settings.
criteria- by name, each{ description, weight? }. The description is the whole of what the judge is told, and each criterion is judged on its own, in its own call: asked about three at once a model averages them in its head. Weights are optional and all-or-nothing, and must add up to 1 - a half-weighted set, or one adding to 1.4, is refused rather than normalised. Without weights a case scores the plain mean of its criteria; with them, the sum of score x weight.reference-facts(short true statements the answer should agree with and not go beyond),answer(one good answer, put to the judge as one of many), or both. Never required: conciseness needs none.metrics-latencyMs,tokens,toolCalls,llmCalls, each with amax. Read from the run itself: the timer, the compiler's own tracer, and the usage the model client reports. Each is reported as{ actual, max, passed }. A client that reports no usage leaves tokensavailable: false, and a metric that could not be measured does not pass - an unknown counted as zero would be a perfect score on cost.mock- the sametoolsandreadsa test file uses, for what an eval must not really do: take a payment, send a message. Not the model. An eval of a mocked answer measures the mock.thresholds-overall, and per-criteria, the scores the suite must reach. A threshold does not turn an eval into a test: the criterion is still a score, and this only says whether it was enough to ship.
The judge is handed to the runner, never named in the file -
runFlowEvals(path, { llm, judge }), both in the shape buildGraph takes. The
file says what to judge; which model judges it is runtime configuration, like
every other model choice here. Its answer is structured and checked by the same
code a v0.15 schema is - { score: 0 to 1, reason } - and a judge that will not
answer in that shape is an error, never a zero.
Every case runs in a conversation of its own, so one cannot see another even
when the flow declares conversation: true. Cases run two at a time unless the
runner is told otherwise, because a dataset launched all at once is a large bill
arriving in one second.
A score of 0 means the agent answered and the answer was worthless. It never
means the evaluation could not happen: a case reports agent_error when the
flow threw and judge_error when the judge would not answer, and neither is
counted in any average.
topic: types#
Four different things say what a value may be, and they are easy to mistake for
one another because they all use the word type. Each answers its own question:
| what says it | where it lives | the question it answers |
|---|---|---|
| channel type | state.<name>.type | what this channel may hold, for the whole run |
| answer schema | an llm node's schema (0.15) | what shape the model must answer with |
| field definition | a tool's inputs, a form node's fields (0.10) | what a person may and must type |
| an outside JSON Schema | an MCP tool's inputSchema, a model saying what it needs | neither - until it is turned into fields (0.17) |
channel type -> what state holds "answer" is a string
answer schema -> what the model must send { category: one of three }
field -> what a person may enter customerId, required, 5 characters
outside schema -> becomes fields {{tool_schema}} on a form nodeThey are not interchangeable, and the words that look shared are not.
requiredbelongs to a schema and to a field. A channel has norequired: a channel is either written or it is not, anddefaultsays where it starts.acceptbelongs to afilechannel - which file types a person may choose. A schema has noaccept; a model is not choosing a file.descriptionbelongs to all of them, and is read by a different reader each time: a channel's is for whoever writes the flow's code, a schema's is put to the model, a field's is shown under the box a person types into.enumbelongs to a schema; a field saysoptions. Same idea, two readers.- A schema's types are the DSL's own words -
object,list,string,number,boolean- not JSON Schema'sarray.
Where they meet. A schema and its output channel must agree: an object
schema needs an object channel, a list schema a list channel, and the
validator refuses a pair that cannot both be true. And a schema can become
fields: the shape a machine must answer with and the shape a person must fill in
are the same shape written for two readers, which is what lets a form take its
fields from a channel at run time ("fields": "{{tool_schema}}") and what lets
an llm node that is missing something put it in front of a person as a form.
Nothing converts the other way: fields are for people, and a channel type is
about storage, not about either.
topic: versions#
Each feature needs at least this version; new flows use the newest. Versions compare as numbers - 0.10 comes after 0.9. Every version is backwards compatible: raising it never breaks a flow.
| feature | version |
|---|---|
| state | 0.2 |
| limits | 0.2 |
| llm | 0.3 |
| onError | 0.3 |
| retry | 0.3 |
| onErrorSkip | 0.32 |
| scopedLimits | 0.3 |
| tools | 0.4 |
| approval | 0.5 |
| fanout | 0.6 |
| describe | 0.7 |
| ask | 0.8 |
| files | 0.9 |
| remembers | 0.9 |
| packages | 0.9 |
| conversation | 0.9 |
| forms | 0.10 |
| read | 0.10 |
| suggestions | 0.10 |
| views | 0.11 |
| page | 0.12 |
| subflow | 0.13 |
| interactiveSubflow | 0.14 |
| structuredOutput | 0.15 |
| dynamicFields | 0.17 |
| askingForFields | 0.17 |
| agent | 0.19 |
| triggers | 0.20 |
| identity | 0.21 |
| users | 0.22 |
| mcp | 0.23 |
| work | 0.24 |
| shell | 0.25 |
| colors | 0.26 |
| layout | 0.26 |
| logo | 0.26 |
| who | 0.26 |
| approvalNeeds | 0.27 |
| approvalWhy | 0.28 |
| approvalExhibits | 0.29 |
| knowledge | 0.30 |
| building | 0.31 |
| chunking by structure | 0.32 |
| database | 0.33 |
describe is the contract (descriptions, inputs, outputs, requires); forms
covers tool inputs, form nodes and editable approvals.
Not every rule is a versioned feature. classification says what kind of file a
flow is and works at any version; tests are a file of their own and follow the
flow they point at; and how a value is drawn for a person is decided by its
shape, which no flow declares.