Writing a flow
The language from the inside: channels, nodes, edges and what the compiler refuses.
It is the same file the platform's own tests are written against — LGFlow/docs/guides/writing-a-flow.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.
The DSL as a walkthrough: channels, nodes, edges, and what each part is for. The exact rules are in the DSL reference; this is the tour.
DSL reference#
Top-level shape#
{
version: "0.1" | "0.2",
start: string, // id of the first node
state?: State, // v0.2 — channel name -> { reducer?, default? }
nodes: Node[],
edges: Edge[]
}version#
The DSL version. This build accepts every version from "0.1" to "0.17"; anything else fails
with Unsupported LGFlow version: 2.0.
A feature used below the version that introduced it is rejected by name, rather than silently ignored:
Declaring state requires version "0.2" or laterstate — v0.2#
Optional. Declare the flow's state channels and the compiler builds the
Annotation.Root for you:
"state": {
"question": {},
"attempts": { "default": 0 },
"notes": { "reducer": "append", "default": [] }
}const graph = buildGraph(flow, registry); // no schema argument needed| Reducer | Combines an update with what is there |
|---|---|
replace | last write wins — the default, and the same as omitting it |
append | concatenates arrays; null clears |
merge | shallow-merges objects; null clears |
Any other name is looked up in the registry, so an unusual combine is a function like any other. Defaults are cloned per run, so one invocation's appends never leak into the next.
Declaring state stays optional. Without it, nothing changes: you pass your own schema, exactly as in v0.1. An explicitly passed schema always wins.
limits — v0.2#
A budget on a route, so a loop cannot run forever without the router policing itself:
{
"from": "evaluate",
"type": "conditional",
"router": "routeEvaluation",
"routes": { "retry": "research", "done": "generateAnswer" },
"limits": { "retry": { "max": 3, "then": "done" } }
}Take retry at most 3 times; after that take done instead. The router goes
back to being a decision — is there enough information? — and stops also
being a budget clerk.
then names another route, not a node, so a limit can never invent a
destination the flow did not already declare.
The count is of visits to the edge's source, which for a loop is how many times
round it has been — exactly what the hand-written attempts >= 3 checks in the
v0.1 examples measured. Routers cannot write state, so the counting is done by
the source node and only read when routing. That is why a limit needs the flow
to declare state: the compiler has to own a channel to count in.
Scoping a limit — v0.3#
By default the count runs for the whole run, which is right for *stop after ten
wrong answers in one sitting* and wrong for two tries at each word. per names
the node whose visit puts the count back to zero:
"limits": { "again": { "max": 2, "per": "pickWord", "then": "reveal" } }Two retries per word, counted again from scratch whenever pickWord runs.
Without this, the word tutor could not use limits for its real budget at all —
check is visited on every word, so an unscoped limit ran out partway through
the lesson and sent every later word straight to reveal.
max caps the route, not the node: max: 2 allows one first attempt and two
retries.
tools — v0.4#
The tools this flow is allowed to reach, by name:
"tools": ["getCustomerDetails", "getCustomerOrder", "addOrder"]Optional. Declared, it is a contract — a tool node naming anything else is a validation error, so you can read a flow file and know every tool it can call without opening a single handler.
The tools themselves are still injected, never configured here:
buildGraph(flow, registry, { callTool }). A flow file stays portable, and no
connection string or command line ever lives in it.
Describing a flow — v0.7#
A flow can say what it is for - enough that its code could be written from it.
None of this changes how it runs: buildGraph ignores every key below.
{
"version": "0.7",
"description": "Research a subject on the web and show the user a short summary of it.",
"inputs": ["subject"],
"outputs": ["summary"],
"requires": { "env": ["OPENAI_API_KEY", "SERPER_API_KEY"] },
"tools": [
{ "name": "web_search", "description": "Search the web and return title, url and snippet for each result." }
],
"state": {
"subject": { "type": "string", "description": "What the user wants to know about." },
"results": { "type": "list", "description": "Search results, each { title, url, snippet }." },
"summary": { "type": "string", "description": "Five sentences at most, with sources." }
},
"nodes": [
{ "id": "check", "handler": "checkSources",
"description": "Drop results without a url, and results whose snippet does not mention the subject." }
],
"edges": [
{ "from": "check", "type": "conditional", "router": "routeSources",
"routes": { "enough": "summarize", "none": "END" },
"when": { "enough": "at least one result survived the check", "none": "nothing usable was found" } }
]
}| Key | Where | What it is for |
|---|---|---|
description | flow | What the whole flow does - the context every function is written in |
inputs · outputs | flow | The channels a run starts from, and the ones to show when it ends. "inputs": [] means "takes nothing" |
requires.env | flow | Environment variables it needs - they become a deployed project's .env.example. A Google access token named here renews itself — see Tokens that renew themselves below |
requires.services | flow | What the host passes in - a payment system, a person, a device - by name, each with a description of how it is called |
requires.settings | flow | Numbers and switches with a default and a description, e.g. "limit": { "default": 100, "description": "..." }. A deployed project takes each one from its .env when it is set there - limit from SETTING_LIMIT, listed in .env.example - so a deployment changes one without editing generated code (0.17) |
description | node | What a handler does, which channels it reads and what it writes |
when | conditional edge | One sentence per route: what the router checks. A router is code too |
description | edge | Optional: what a router weighs up |
type · description | channel | string, number, boolean, list, object, any or file (0.9) - the contract between functions written apart |
{ name, description } | tools | A tool a tool node calls, described so it can be generated |
The channel type matters most. Every handler reads what one handler wrote and writes what another reads; generated separately, they will disagree about the shape of that data unless it is written down.
Ready to generate#
validateFlow answers will this compile? checkReadiness answers *is enough
written down to generate code?* - a different question:
import { checkReadiness } from "lgflow";
const { ready, findings, plan } = checkReadiness(flow);
// plan: { handlers: ["checkSources"], routers: ["routeSources"], tools: ["web_search"],
// services: ["payments"], declared: ["crm", "crm.findCustomer"] }Four of those lists are one kind and the fifth is another. handlers,
routers, tools and services are units: a model writes each one from
the description you gave it, so a thin description is a defect and every check
below is about them — this is what lgflow check prints as Would write. A
services entry is written from the paragraph in requires.services, which
makes it the file to read hardest before you deploy: naming the endpoint, the
request and the variable holding the key is what stops a model inventing them.
declared is what an MCP server brings: "<server>" for its client and
"<server>.<tool>" for each of its tools, generated from requires.mcp with
no description involved, and printed on its own line as *From the
declaration*. It names every tool the server declares, not only the ones a
node calls, because a file is written for each of them either way.
*They were three lists until 2026-11-07, and the plan was wrong in three ways
at once: a called MCP tool was listed among the units, so it read as wanting a
better description; a declared one nothing called was not listed at all while
its file was written regardless; and services was in no list ever, so the
one file here that is written from a paragraph of prose was the one lgflow
check never mentioned.*
Ready means: the flow is valid, it has a description, inputs and outputs, every
channel has a type, every handler node is described, every route has a sentence,
and every tool a tool node calls is declared with a description. Descriptions
under four words are flagged as too thin, and a tool that says it fetches, searches or
generates something without naming an endpoint is flagged too - both warnings,
not refusals. Readiness also warns about what a run will get wrong later: a tool
that writes with nobody asked first (WRITE_WITHOUT_APPROVAL), a conversation
whose prompts never read {{history}} (CONVERSATION_UNREAD), a channel counted
across turns without remembers (CHANNEL_FORGETS_BETWEEN_TURNS), an ask node
overwriting what the run was handed (ASK_OVERWRITES_INPUT), a view reading a
channel nothing fills (VIEW_NEVER_SHOWN), a card showing a whole list or object,
which comes out as JSON in the conversation (VIEW_SHOWS_RAW_DATA), an MCP
service with no package (MCP_PACKAGE_MISSING) and a service used but never
declared (SERVICE_NOT_DECLARED). A tool description without an endpoint is where guesses come from:
the model picks a provider, and its own idea of that provider’s parameters.
Typed nodes need none of it. An llm node's prompt, a tool node's call and a
fan-out's spread are already complete, and LGFlow compiles them itself - plan
lists only what would have to be written. The more of a flow is typed, the less
code is generated, and the less there is to get wrong.
Asking the user — v0.8#
An ask node puts a question to whoever is on the other end - the chat, LGStudio's
Play, a terminal - waits, and writes the reply to a channel:
{ "id": "clarify", "type": "ask", "ask": "What about {{topic}} interests you most?", "output": "focus" }The run stops there. In the chat the question appears as the agent's message and
your next message is the answer; in Play it is a card with a reply box; run once
at a terminal, it is asked there. Then the run carries on with focus filled in.
When the channel the answer goes to is a file channel, the question says so
(expects: "file", with the channel's accept), and the chat and Play offer a
file picker instead of somewhere to type - nobody can type a photo. The answer
sent back is then the file itself, not a sentence about it.
It is compiled by LGFlow, like an llm node - no code is generated for it - and,
like approval, it needs a checkpointer to wait in (generated projects have one).
A pause is never mistaken for a failure: onError and retry let it through.
Use it for questions that only arise once the run has started. What the user
gives at the start is an input, not a question.
Files in and out — v0.9#
A channel can hold a file: a photo someone sends, a picture the agent draws, a recording, a document.
"photo": { "type": "file", "accept": "image/*", "description": "The photo to look at." },
"picture": { "type": "file", "description": "The picture that was drawn." }The value is always the same small object, whatever the file is - never the bytes:
{ id: "8f2…png", name: "cat.png", mediaType: "image/png", url: "/files/8f2…png", bytes: 84213 }The bytes live in the project's own store, which generated code reaches as
services.files (there whenever a flow has a file channel, declared or not):
const bytes = await services.files.read(state.photo); // Buffer
const base64 = await services.files.base64(state.photo); // what a model API wants
const saved = await services.files.save({ name: "picture.png", mediaType: "image/png", data });
return { picture: saved }; // what a file channel holdsBecause the type says so, the pages build themselves: a file input becomes
a drop / paste / browse control in the chat and a file picker in Play; a file
output is rendered - an image inline, audio or video in a player, anything
else as a download. run.js --serve serves the store at /files/<id> and takes
uploads at POST /files. Nothing here is per-flow: one page, one contract.
A flow that is a conversation — v0.9#
Every message is its own run, which is what keeps one question from bleeding into the next - and it also meant a chat could not be a chat. One flag fixes that:
{ "version": "0.9", "conversation": true }The flow gets a history channel it did not have to declare: a list, appended
to, holding one entry per finished turn - { at, asked, answered }. The served
agent writes it; no node has to. Any prompt can read it:
{ "id": "decide", "type": "llm",
"prompt": "So far: {{history}}
Now: {{question}}", "output": "plan" }That is what lets the next message say "them", "those customers" or "do it
again" and be understood. Each conversation (thread) keeps its own; nothing
crosses between them. checkReadiness warns (CONVERSATION_UNREAD) when a
conversational flow has prompts and none of them reads the transcript.
LGStudio starts every new flow with it on.
What survives the message — v0.9#
Every message someone sends the chat is its own run: each channel starts again
from its default, which is what keeps one photo from bleeding into the next.
Two kinds of channel are the exception. An append or merge channel carries
itself - that is how a transcript grows. Anything else that has to outlive the
message that wrote it says so:
"budget": { "type": "number", "description": "The tokens this conversation may spend.",
"remembers": true, "default": 0 },
"spent": { "type": "number", "description": "Tokens spent so far, across every photo.",
"remembers": true, "default": 0 }Without remembers, a running total is back at zero by the next message and
nothing ever adds up - the flow looks right and the number lies. A new value
sent with a message still wins, so a remembered budget can be changed at any
time by typing a new one.
checkReadiness warns (CHANNEL_FORGETS_BETWEEN_TURNS) when a channel is
described as counting "so far", "cumulative" or "across the conversation" and
nothing carries it.
A reducer is not a memory. append and merge say how two writes combine
within one run — a fan-out gathering its copies, a handler adding to a list.
They carry nothing into the next message: remembers is the only thing that
does, and a channel that should accumulate across a conversation says both. It
used to be otherwise, and since the DSL requires a fan-out channel to append,
an agent that split a question into tasks answered the second message with the
first message's tasks still in it — both sets of results, one after the other.
Guarding a budget is a shape as much as a flag: the node that checks must come
before the node that spends, with a conditional edge - ok to the work,
exceeded to a node that says so. A check after the call has already spent what
it was guarding.
The packages the code needs - v0.9#
Generated code may import Node built-ins and nothing else - unless the flow says otherwise:
"requires": {
"packages": { "@modelcontextprotocol/sdk": "^1.30.0" },
"services": { "mcpDb": "A client for the mcpDb MCP server, spoken to over stdio…" }
}Those go into the generated package.json, so npm install brings them, and
the verifier lets a unit import them. Everything else is still refused: a
generated project installs exactly what its flow declared. A package that is
not installed at generation time is checked for syntax and imported later,
which is reported as a warning rather than a failure.
This is what makes a service that talks a protocol possible - an MCP server over stdio, a database driver - written from its description like any other unit.
What it has said and done - the journal#
A run is streamed and then gone. The same calls are also written down: every
model call and every tool call, one JSON object per line, in the project's own
.lgflow/journal.jsonl.
import { openJournal, journalledCallTool, journalledLlm } from "lgflow/journal";
const journal = openJournal({ file: new URL("./.lgflow/journal.jsonl", import.meta.url) });
await serveAgent({ journal, llm, callTool, createAgent, flow });A generated project does this for you. Each entry says when, which kind
(llm or tool), the label or tool name, what was sent, what came back, how
long it took and which conversation it belonged to; long text is kept as its
opening, and the file is capped. A model call also records what it spent, when
the model says so - tokens: { input, output, cached, total } - which is the
Tokens column in the chat page's History and in LGStudio's. The model client
reports it through an onUsage callback: an llm(prompt, label, { onUsage })
that ignores the third argument simply records no tokens. Writing is queued and never fails a run - a
journal that cannot be written simply is not one.
The served agent reads it back at GET /calls?limit=&kind=, which is what the
chat page's History shows, and what LGStudio's History panel puts beside
its own.
The conversation itself, which is a different record#
The journal says what the agent did; the transcript says what was said -
one line per turn, kept in .lgflow/transcript.jsonl. They are two stores on
purpose: a journal is capped by lines and read by a board, and a conversation
is content, kept per conversation and for as long as TRANSCRIPT_DAYS says.
It is why opening an agent is no longer opening an empty room. The page asks
GET /conversations for the list - the pointer only, no text - and
GET /conversation?thread= for one of them, and draws the most recent one it
is allowed to see, then goes on with it: the next question joins that
conversation rather than starting a second one about the same thing.
And the agent is shown the conversation again (0.36). This paragraph said the opposite until that version - that graph state dies with the process, so after a restart the words were on screen and "as I said earlier" meant nothing. Measuring it turned both halves over:
- The state does survive. A deployed agent's store is
.lgflow/waiting.db, from 0.26.6, and thehistorychannel is in it. - What did not survive was exactly one turn - the last completed one, which the server holds in memory until the following turn consumes it. So the symptom was never a blank agent; it was an agent that had forgotten precisely the last thing anybody said to it, permanently, while that thing sat on the screen in front of them.
The first turn of a thread this process has not been talking to now reads the
last KEPT_TURNS (20) out of the transcript and hands them over. Nothing has
to work out which ones are missing: the channel drops a turn it already
holds, matched on at, and a turn is written with one timestamp so the two
copies of it agree. The same change gave the channel its first ceiling - it
had none, and turn 30 of a thread was shown 29 turns.
The chat page#
run.js --serve serves one page for every agent: the flow decides what it asks
for and what it shows, so there is nothing per-project to write or style. It is
set in Atkinson Hyperlegible, drawn for low vision and carried inside the
page as woff2 - no network, no flash of another face, and letters that are often
confused (l 1 I, O 0, b d) drawn apart. Copyright 2020 Braille Institute of
America, Inc., SIL Open Font License 1.1: src/LICENSE-Atkinson-Hyperlegible.
Hebrew and anything else the font does not cover falls through to the platform
faces behind it.
How big the whole page reads is one number in src/chat.html - --text on
:root, with every other size a fraction of it.
Tables. An answer that contains a Markdown table is drawn as a real table - column headers, numbers aligned right, the text around it left as text - and an output that is a list of records becomes a table with one column per field. So an agent that lists things needs no page code: its answering prompt asks for a Markdown table with named columns ("Date, From, Subject"), and the page does the rest. One item, or a plain answer, stays prose.
A question that lists things to choose between — which order, which room,
which date — is read the same way, so it is drawn the same way: build the
Markdown table in a handler, into a channel of its own, and read that channel
into the ask node's question. Nothing without a table in it changes: a line only
becomes a table when it holds | and the line under it is a | --- | rule.
The page it sits inside is a flow's shell section (0.25): a brand mark of
initials, a column each side, a strip above, a line below. It is scenery and
nothing in it is live — a stat holds the characters somebody typed, and what
shows the agent's own figures while it runs is views. Blocks are typed (nav,
stat, note, links, status, who) rather than markup, for the reason
page.css cannot load anything either: the worst a page should be able to do is
look wrong. A flow with no shell is served the page exactly as it was. The
six ready-made ones are in src/templates.js, and LGStudio's Chat template
tab shows them as what they really look like — reference topic shell, design
in docs/chat-shell.md.
start#
The id of the first node. The compiler turns it into addEdge(START, "<start>"),
so you do not write a START edge yourself.
Nodes#
{ id: string, handler: string }id names the step in the graph; handler names the JavaScript function that
implements it. Ids must be unique, and START / END are reserved.
From v0.3 a node may declare a type instead of a handler, and the compiler
writes the handler for it:
type | Version | What it does |
|---|---|---|
| (none) | 0.1 | Runs the named handler |
llm | 0.3 | Fills a prompt from state, asks the model, stores the reply |
tool | 0.4 | Calls one named tool with arguments read from state |
tools | 0.4 | Runs every call waiting in a channel — whoever asked for them |
fanout | 0.6 | One copy of another node per item in a list, all at once |
ask | 0.8 | Stops, puts a question to the person, and waits for the reply |
form | 0.10 | Stops with a form of several fields; the answers arrive as one object |
read | 0.10 | Reads a PDF, Word file, web page or text into a channel |
flow | 0.13 | Runs another flow file as one step |
A typed node has no entry in the registry at all. A flow can be entirely typed
nodes, with no handlers.js to go with it.
llm nodes — v0.3#
{
"id": "gradeDocuments",
"type": "llm",
"prompt": "Do these sources answer the question?\n\nQ: {{question}}\n\n{{documents}}\n\nReply YES or NO.",
"output": "documentsRelevant"
}{{channel}} reads state. A string goes in as it stands; anything else goes in
as JSON, so a list of retrieved documents arrives in a shape a model can read. A
channel holding nothing renders as a gap, never as the word "undefined".
The reply, trimmed, is written to output. The model is injected —
buildGraph(flow, registry, { llm }) — which is what lets the chat page report
every prompt an llm node sends. Each call is labelled with the node id, so a
flow with four of them shows four named rows rather than four saying "llm".
Validation checks that every channel a prompt reads, and the one it writes, is actually declared.
Structured output — v0.15#
When the answer is data rather than prose, say what shape it must have:
{
"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"]
}
}After the node runs, state.classification is the object itself:
{ category: "billing", confidence: 0.94, reason: "The user is asking about a duplicate charge." }so the router downstream reads classification.category instead of reading a
sentence and hoping.
Three fields, three jobs — and none of them done 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. A prompt that also says "return JSON", "and nothing else", or lists the property names again is saying the same thing in two places — and the two will disagree the first time either is edited. Write the prompt as if a person were going to answer it.
The path is ask → parse → check → write, and nothing skips the middle:
- Ask. The prompt and the schema go together. It is handed to the model
client too, as
llm(prompt, label, { schema }), so a client that can ask its provider for that shape natively may; one that cannot ignores it and the words still stand. - Parse. The same reading as before: a code fence, a sentence in front, a Hebrew quote left loose inside a string — all still handled.
- Check. Recursively, against the declared shape, with paths a person can
act on:
classification.category must be one of: technical, billing, general,tasks[2].priority must be a string, and it is a number, 3. - Write — or not at all. An answer that does not fit is never written, not
even the part of it that did fit. The node fails like any other, so
retrygives the model another go andonErrorcatches what is left, with the reason in__error. There is no second error system for schemas.
A schema says type, description, properties, required, items, enum,
minimum, maximum, minLength, maxLength — and nothing else. It is a small
subset of JSON Schema on purpose, in the DSL's own words (list, never
array), and a keyword LGFlow does not know is refused by name rather than
ignored, because a schema quietly half-applied is worse than no schema at all.
A list must say what its items are. Schemas nest as deep as the answer does.
The schema and the output channel are two statements about one value, so the compiler makes them agree before anything runs:
LLM node "classify" schema type "object" is incompatible with output channel
"classification" of type "string"false, 0, "", [] and {} are answers — only an absent or null property
is missing. An llm node with no schema behaves exactly as it did before 0.15,
including reading JSON out of a reply by itself when the channel is an object
or a list. Readiness points out an object or list channel filled from a model
with no schema, as a warning — the flow still generates, it can just say
more than it does.
examples/structured-classifier is it working: npm run example:structured,
which runs on a stand-in model when there is no key, and -- --bad to watch an
answer of the wrong shape become a retry and then an onError.
tool and tools nodes — v0.4#
The flow decides:
{
"id": "lookup",
"type": "tool",
"tool": "getCustomerDetails",
"args": { "customerId": "{{customerId}}" },
"output": "customer"
}Arguments are templated like prompts, with one addition: a string that is
only a placeholder passes the value, not its text — so a tool schema
wanting a number gets 5 rather than "5".
Something else decides, and the flow runs what it asked for:
{ "id": "runTools", "type": "tools", "calls": "pendingCalls", "output": "toolResults" }calls holds { name, args, id } entries. Each result comes back as
{ id, name, result, error }, and one failing call does not stop the others —
its error is reported in its own result, because what asked for the call is
usually a model, and a model can only recover from what it is told. The queue is
emptied by running it, so a loop does not call the same tools twice.
That difference — the flow choosing versus the model choosing — is exactly what
examples/mcp-order-desk and examples/mcp-tool-calling exist to compare, and
it is now visible in the flow file rather than buried in a handler.
fanout nodes — v0.6#
{ "id": "spread", "type": "fanout", "over": "documents", "to": "grade", "as": "document" }One copy of grade per item in documents, each reading its own item from
document and the rest of state as normal. The copies run at the same time,
and whatever follows grade runs once, after all of them.
A fanout node declares no edges of its own — the compiler writes them, because the destination is not one edge but one per item.
Every copy writes the same channel in the same step, so that channel needs a reducer. When the target is a typed node the compiler knows which channel it writes and says so before anything runs:
grade writes "summary" once per item, so that channel needs a reducer - "append" collects the resultsPlain fan-out — two ordinary edges out of one node — has worked since v0.1, because that is what LangGraph does with two edges. The same reducer rule applies, and there the compiler cannot check it for you.
An empty list sends nothing, and a branch that sends nothing ends. So
whatever was supposed to run after the copies does not run either — and
that is not an error anywhere: no refusal, nothing in the journal saying the
person got no answer because a list was empty. If documents comes back
empty, the run above finishes having written nothing at all.
Route around it from the node in front of the fan-out, which needs no new field:
{ "from": "gather", "type": "conditional", "router": "routeBatch",
"routes": { "review": "spread", "none": "summarize" },
"when": { "review": "'documents' has at least one document in it",
"none": "anything else: 'documents' is empty, so go straight to the summary" } }Two things decide whether that works. Route from the node before the fan-out, not from the fan-out — a fan-out declares no edges of its own, by the paragraph above. And route to the node that runs once after the items, which is often not the last node: if a gatherer writes what the report reads, route to the gatherer.
Readiness says so on a flow of this shape, as FANOUT_MAY_SILENCE_THE_TAIL
— advice, not a refusal, because the shape is legitimate when the tail is
reachable another way. examples/parallel-review/flow.json is the worked
version, and the reference's fanout topic has the same shape beside the
rule it depends on. *It is worth routing even when you are sure the list is
never empty: a card in the Solution Library carried a long, careful
description of what to write when nothing could be turned into a query, and
the fan-out in front of that node meant the run never arrived there in
exactly that case.*
onError and retry — v0.3#
{ "id": "retrieve", "handler": "retrieve", "retry": { "maxAttempts": 3 }, "onError": "escalate" }retry gives the node more goes before a failure counts. onError names where
the run goes when it still fails — a node id, or END.
The flow's own edges are untouched: the compiler turns the node's way out into a
conditional one and adds the recovery route to it, so recovery is declared on
the node rather than drawn as another branch to keep in step by hand. The
failure is recorded in __error as { node, message }, which the recovering
node can read to say what went wrong. It is cleared whenever the node succeeds,
so a second pass through a loop is not diverted by an old error.
Without onError, a throw still ends the run — the v0.1 behaviour, unchanged.
approval — v0.5#
{
"id": "place",
"handler": "placeOrder",
"approval": { "ask": "Place this order?", "show": ["order"], "onReject": "cancel" }
}The run stops before the node does anything, saves itself, and waits. The
paused run reports { node, ask, show }; answering resumes it where it stopped:
const paused = await graph.invoke({}, config); // paused.__interrupt__[0].value
await graph.invoke(new Command({ resume: "yes" }), config);shownames channels, not the whole state. An approval request goes to a person, and handing them everything is both unreadable and a good way to leak something.- Refused means never ran. The handler is not called at all, and the run
takes
onReject. That is not a rolled-back write; it is a write that never happened. - Anything unrecognised is a no.
yes,y,approve,ok,trueand{ approved: true }mean yes; everything else means no, because the point of asking is that silence must not count as consent. - A refusal needs a word. LangGraph rejects an empty
Command, sofalse,null,0and""never reach the flow. A button saying no must send"no"or{ approved: false }. - A checkpointer is required. A paused run lives in one; without it the first
approval would end the run rather than hold it, so
buildGraphrefuses to compile.MemorySaverforgets on restart — use a persistent checkpointer if approvals outlive the process.
Keeping the two separate means one function can back several steps:
[
{ "id": "billingFirstCheck", "handler": "solveBilling" },
{ "id": "billingSecondCheck", "handler": "solveBilling" }
]Compiles to graph.addNode("classify", registry.classifyRequest).
Normal edges#
{ from: string, to: string }Compiles to graph.addEdge("research", "evaluate").
"END" is a reserved destination and maps to the LangGraph END constant.
Conditional edges#
{
from: string,
type: "conditional",
router: string,
routes: { [routeName: string]: string } // destination node id, or "END"
}{
"from": "classify",
"type": "conditional",
"router": "routeRequest",
"routes": {
"technical": "technicalSupport",
"billing": "billingSupport",
"general": "generalAnswer"
}
}Compiles to graph.addConditionalEdges("classify", registry.routeRequest, { ... }).
A router is a plain function whose return value must be one of the routes keys:
function routeRequest(state) {
if (state.category === "TECHNICAL") return "technical";
if (state.category === "BILLING") return "billing";
return "general";
}Routers may be async. If a router returns something that is not a declared
route, LGFlow throws an LGFlowRoutingError naming the router, the edge and the
routes that were declared — instead of failing deep inside LangGraph.
Loops#
There is no loop syntax. A loop is a conditional edge pointing back at an earlier node:
{
"from": "evaluate",
"type": "conditional",
"router": "routeEvaluation",
"routes": { "retry": "research", "done": "generateAnswer" }
}Registry#
The registry maps handler and router names to real functions, because JSON cannot hold a function reference:
const registry = { classifyRequest, routeRequest, solveTechnical };A module namespace object works too, which is usually tidier:
import * as handlers from "./handlers.js";
buildGraph(State, flow, handlers);Lookup uses own properties only, so inherited members such as toString are
never mistaken for handlers.
Reserved values#
| Token | Meaning |
|---|---|
"END" | The LangGraph END constant. Valid as to and as a route destination. |
"START" | The LangGraph START constant. Accepted as an edge from, but prefer the top-level start. |
The DSL reference#
docs/reference/dsl-reference.md is the whole DSL in one place, split into
topics - the overview, then channels, each kind of node, edges, forms, reading,
conversations, the contract, services, recipes and patterns - with complete
example flows in docs/reference/examples/. It is what LGStudio's Ask bar builds
flows from (src/reference.js reads it).
It is held to the compiler by test/reference.test.js: the version it names,
every node kind, channel type and field type, and every entry of
FEATURE_VERSIONS must be in it, and every example must be a valid, ready flow
at the current version. Upgrading the DSL means updating the reference in the
same change - the tests fail until it is.
Validation rules#
| Situation | Error |
|---|---|
| Unsupported version | Unsupported LGFlow version: 2.0 |
| Duplicate node id | Duplicate node id: research |
Unknown start | Start node not found: unknown |
| Handler missing from registry | Handler not found: doResearch |
| Unknown edge source | Edge source node not found: something |
| Unknown edge destination | Edge destination node not found: something |
| Router missing from registry | Router not found: routeSomething |
| Unknown conditional destination | Conditional destination not found: missingNode |
Alongside these, LGFlow reports structural problems — missing version, start,
nodes or edges; a node without id or handler; a reserved node id; a
conditional edge missing type, router or routes; a conditional edge that
also defines to; an unknown edge type; a handler or router that is registered
but is not a function.
LGFlow validates the DSL. LangGraph still applies its own checks at .compile()
— most usefully, it rejects a node that nothing routes to
(UnreachableNodeError), so removing the last route into a node is caught even
though the DSL itself is well-formed.