Symphonix Documentation · Core 0.61.0

Writing a flow

The language from the inside: channels, nodes, edges and what the compiler refuses.

This is the engineering reference, rendered from the repository

It is the same file the platform's own tests are written against — LGFlow/docs/guides/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 later

state — 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
ReducerCombines an update with what is there
replacelast write wins — the default, and the same as omitting it
appendconcatenates arrays; null clears
mergeshallow-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" } }
  ]
}
KeyWhereWhat it is for
descriptionflowWhat the whole flow does - the context every function is written in
inputs · outputsflowThe channels a run starts from, and the ones to show when it ends. "inputs": [] means "takes nothing"
requires.envflowEnvironment 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.servicesflowWhat the host passes in - a payment system, a person, a device - by name, each with a description of how it is called
requires.settingsflowNumbers 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)
descriptionnodeWhat a handler does, which channels it reads and what it writes
whenconditional edgeOne sentence per route: what the router checks. A router is code too
descriptionedgeOptional: what a router weighs up
type · descriptionchannelstring, number, boolean, list, object, any or file (0.9) - the contract between functions written apart
{ name, description }toolsA 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 holds

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

typeVersionWhat it does
(none)0.1Runs the named handler
llm0.3Fills a prompt from state, asks the model, stores the reply
tool0.4Calls one named tool with arguments read from state
tools0.4Runs every call waiting in a channel — whoever asked for them
fanout0.6One copy of another node per item in a list, all at once
ask0.8Stops, puts a question to the person, and waits for the reply
form0.10Stops with a form of several fields; the answers arrive as one object
read0.10Reads a PDF, Word file, web page or text into a channel
flow0.13Runs 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:

promptwhat the model should do
schemathe exact shape it must answer with
outputwhich 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:

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 results

Plain 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);

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#

TokenMeaning
"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#

SituationError
Unsupported versionUnsupported LGFlow version: 2.0
Duplicate node idDuplicate node id: research
Unknown startStart node not found: unknown
Handler missing from registryHandler not found: doResearch
Unknown edge sourceEdge source node not found: something
Unknown edge destinationEdge destination node not found: something
Router missing from registryRouter not found: routeSomething
Unknown conditional destinationConditional 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.