Symphonix Documentation · Core 0.61.0

The DSL reference

Every node type, channel, edge and requirement a flow may declare, version by version.

This is the engineering reference, rendered from the repository

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

kindwhat it doesneeds
handlercode generated from its descriptionhandler, description
llmfills a prompt from state, asks a model, writes the replyprompt, output
toolmakes one named tool call with arguments from statetool, args, output
toolsmakes every call queued in a channel (calls a model chose, or a batch)calls, output
fanoutruns one node once per item of a list, in parallelover, as, to
askpauses and asks the person one questionask, output
formpauses and asks for several fields at oncefields, output
readreads a document (PDF, Word, web page, text) into a channelfrom, output
flowruns another flow as one step, with its own stateflow, input, output
agentruns a registered agent chosen while the run is happeningagents, agent, input, output
workhands a model the tools and lets it call them until it has an answerprompt, tools, output
knowledgeasks a declared collection a question and keeps the paragraphs that answer itfrom, ask, output
indexbuilds a declared collection out of documents already readinto, documents, output
databaseruns one read against a declared database and keeps the rows, or asks what its tables arefrom, 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:

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." }
}

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 in

LGFlow 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.

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.

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"? }]

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" }

needs (0.27) names the policy this approval asks for:

"approval": { "ask": "Pay this invoice?", "show": ["amount"], "needs": "two-eyes" }
{ "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" }

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"] }

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" } } }
{ "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.

topic: errors#

{ "id": "lookup", "type": "tool", "tool": "getCustomerOrder", "args": {...}, "output": "facts",
  "retry": { "maxAttempts": 3 }, "onError": "write_answer" }

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

  1. caps the text; allowPrivate: true allows addresses on this machine.
  2. Sources with {{ }} are what a message brings - a file channel the person uploads, or a URL they give. The first with something in it is read.
  3. 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.
  4. With nothing new to read the node writes nothing, so the channel keeps the document read earlier - give it "remembers": true.
  5. Refused, with a message the person can act on: scanned PDFs (no text), old .doc files, 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" }
  }
}

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.

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.

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.

methodwhat it does
fixedsplit every size characters, never mid-word0.31
structurethe document's own headings, clause numbers and lettered sub-clauses; whole sections packed to size0.32
recursiveparagraphs, then sentences, then words as neededplanned
sentencewhole sentences grouped to a sizeplanned
semanticsplit where the subject changesplanned
slidingoverlapping chunksplanned
parent-childindex the small chunk, return the section around itplanned
agenticask a model where the boundaries areplanned

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.

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.

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#

topic: contract#

From 0.7 a flow says what it is, because its code is generated from what it says:

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" } } }
      ]
    }
  }
}

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 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:

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:

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.

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#

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

typeshowstakes
carda title, a line of text, label/value items and a progress bar - any of themtitle, text, items, progress
imagea picture from a file channelfile (one template), caption
actiona button under a question; pressing it answers the questionlabel, 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 valuedrawn as
several records sharing fieldsa table, one row each
one record with named fieldsa form with nothing to fill in, labels and all
a record whose values are lists of recordsone table per key, under that key
a value with lines of its owna block, under its name, keeping the lines
anything elsetext, 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.

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.

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:

  1. Give it a channel of its own, written by that node, and add a view that reads it.
  2. Change that node's description so 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; }"
}
settingwhat it doesvalues
questionhow the agent's questions readsize: normal, large, x-large; bold: true/false; color: #rrggbb
fontScaleall the text, bigger or smaller0.8 to 1.6 (1 is as it is)
fontthe typefacedefault, rounded, serif, mono
accentbuttons, links, highlights#rgb or #rrggbb
backgroundthe page behind the conversation#rgb or #rrggbb
directionwhich way text runsauto (each message decides), rtl, ltr
widthhow wide the conversation runsnormal, wide
titlethe name in the header and the tabtext, at most 80 characters
welcomethe words before the first messagetext, at most 400 characters
hideparts of the page to leave outhistory, newChat, about, suggestions, hint
layouthow the page is divided and where its parts sit (0.26)classic, messenger, column
logoshow the deployment's own logo in the bar (0.26)true/false
whoshow the signed-in person's name in the bar (0.26)true/false
colorseach part of the page, its own two colours (0.26)see below
cssanything the settings do not covera 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.

layoutthe shape it gives the page
classica bar across the top, the conversation down the middle, the box fixed at the bottom with the suggestions under it
messengerthe name over its description in a slim bar, a wider conversation, and the box floating above the bottom edge with the suggestions over it
columnno 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" }
}
partwhat it istakes
headerthe bar across the top: the name, the description, History and New chatbackground, text
rulethe hairlines between the bar, the conversation and the composerone colour
deskthe surface the conversation sits on, and the words on itbackground, text
welcomethe first screen: the agent's name in large type, and the words under ittitle, text
composerthe box the person types inbackground, text
footerthe strip the box and the chips sit inbackground, text
suggestionsthe chips under the boxbackground, 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.

regionwhere it goes
brandthe top of the sidebar: initials (1-3 characters), name, note, color
asidea column before the conversation
raila column after it
abovea strip between the header and the conversation, full width
belowa 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):

typeshowstakes
nava list of labels, one of them currenttitle, items - each a label or { "label", "on" }
stata figure with its namelabel, value, note
notea small panel of wordstitle, text
linksa list of titlestitle, items
statusa dot and a wordlabel, state: ok, busy, off
whowho is signed inlabel

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.

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.

{ "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:

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

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:

expectationis
stateeach 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
outputthe same, over the flow's declared outputs
paththe node ids that ran, in order
toolCalled · toolNotCalleda tool name, or a list of them
approvalBeforethat 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
failstrue, 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.

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 itwhere it livesthe question it answers
channel typestate.<name>.typewhat this channel may hold, for the whole run
answer schemaan llm node's schema (0.15)what shape the model must answer with
field definitiona tool's inputs, a form node's fields (0.10)what a person may and must type
an outside JSON Schemaan MCP tool's inputSchema, a model saying what it needsneither - 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 node

They are not interchangeable, and the words that look shared are not.

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.

featureversion
state0.2
limits0.2
llm0.3
onError0.3
retry0.3
onErrorSkip0.32
scopedLimits0.3
tools0.4
approval0.5
fanout0.6
describe0.7
ask0.8
files0.9
remembers0.9
packages0.9
conversation0.9
forms0.10
read0.10
suggestions0.10
views0.11
page0.12
subflow0.13
interactiveSubflow0.14
structuredOutput0.15
dynamicFields0.17
askingForFields0.17
agent0.19
triggers0.20
identity0.21
users0.22
mcp0.23
work0.24
shell0.25
colors0.26
layout0.26
logo0.26
who0.26
approvalNeeds0.27
approvalWhy0.28
approvalExhibits0.29
knowledge0.30
building0.31
chunking by structure0.32
database0.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.