Symphonix Documentation · Core 0.61.0

Worked examples

The example flows in the repository, and what each one demonstrates.

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/worked-examples.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.

Whole agents, read end to end. Each one is in examples/ and runs.

A tutor for five words#

examples/word-tutor/ teaches a Hebrew-speaking child five English words and practises each one. It is the first example written against v0.2: the flow declares its own state, so there is no Annotation.Root in the example at all.

Hebrew is the language of instruction. The pupil is assumed to know no English beyond the word being learned, so every meaning, question, hint and piece of praise is in Hebrew, and the English word is the only English on the page. Each word also carries an emoji, because a child who cannot read cat can still read 🐱.

greet -+- next -> pickWord -> teach -> ask -> check -+- correct -> praise -+
       |                        ^                    |                     |
       |                        +--- hint <----------+- again              +-> advance -+- next -> pickWord
       |                                             |                     |            |
       +- done ------------------------------------- + reveal ------------+            +- done -> report

Two nested loops — the inner one asks again after a hint, the outer one walks the word list — which no other example has, and which turned out to be the interesting part.

npm run tutor                  # in the browser — the nicest way to see it
npm run example:tutor          # a scripted child in the terminal, no typing
npm run example:tutor -- --me  # answer at the terminal yourself

In the browser#

npm run tutor puts the lesson at http://127.0.0.1:5175 — a right-to-left Hebrew page built for a child: a big emoji, the English word large and playful, its Hebrew meaning under it, an example sentence with its translation, and five stars across the top that light up as each word is finished.

The answer box is left-to-right and set in the English face, because that is what the child is being asked to type. Everything around it is Hebrew.

The browser is the learner. ask awaits the injected learner function, so the server's version sends the question down an event stream and returns a promise that stays unresolved until the child posts an answer back. The graph sits parked, mid-run, exactly as it does for a person at a terminal — there is a test that asserts nothing else happens while it waits.

Nothing in the flow, the handlers or the lesson changes to make that work. The learner was always a function; only who supplies it is different.

The page paces itself: praise stays on screen for a beat before the next word arrives, because the graph answers in milliseconds and a child cannot read "Yes! Well done." in the frame it existed for.

A single scripted run shows all three endings: right first time, right after a hint, and out of goes.

מילה 1 מתוך 5
  🐱  cat — חתול
  לדוגמה: The cat is sleeping.
          החתול ישן.
  איך אומרים "חתול" באנגלית?
  יפה מאוד! cat זה חתול.

The score itself stays in English — first try, with a hint, shown — because it is a key the flow and the tests work with, not something anyone reads. Every surface translates it on the way out.

The child is an injected function, so the same flow runs against a person at a terminal or an answer sheet in a test.

What nesting taught us#

A route limit counted for the whole run, not per loop. check is visited on every word, so a limit on the again route ran out partway through the lesson and sent every later word straight to reveal. The per-word budget had to be counted by hand in routeAnswer instead.

That is what per fixed in v0.3:

"limits": { "again": { "max": 1, "per": "pickWord", "then": "reveal" } }

routeAnswer is now one line — was the answer right? — and the counter is gone. The lesson behaves exactly as it did, which is the point: the flow says what it always meant, and one fewer thing is written twice.

Two more things this example surfaced, both now caught by validation or documented:


A production-shaped agent#

examples/self-correcting-rag/ is the pattern most teams actually deploy — a RAG agent that grades its own work — built entirely from v0.1's nodes, conditional edges and loops. It answers questions about LGFlow from LGFlow's own documentation.

START → retrieve → gradeDocuments ─┬─ good ──→ generate → gradeAnswer ─┬─ grounded ──→ END
             ↑                     ├─ weak ──→ rewriteQuery ─┘         ├─ invented ──→ generate
             └─────────────────────┘                                   └─ exhausted ─→ escalate → END

Three things separate this from a demo, and each is just routing:

npm run example:rag -- "your question here"

Ask something this file covers directly and it answers first try. Ask the same thing in everyday words and you should see it search, reject what it found, rephrase, and search again. Ask about a topic these docs do not cover — Kubernetes, a nightly backup — and it escalates instead of inventing an answer. The example's header comment lists a question for each path.

The corpus is a snapshot of this README (examples/self-correcting-rag/corpus.md, refreshed with npm run rag:corpus), chunked at its headings and read from disk at startup, so the agent cannot drift out of date. That also means anything written here becomes retrievable — which is why the sample questions live in the example's source rather than on this page, so they cannot match themselves.

Runs offline by default (BM25 keyword retrieval, threshold-based graders) so the tests are free and deterministic. Set OPENAI_API_KEY and the same flow runs the graders, rewriter and generator through an LLM — no edge changes, which is the point: retrieval and judgement are handler concerns, not flow concerns.

What it shows v0.1 is missing#

Building this surfaced four gaps. Three are now closed:

GapWhere it hurtNow
Retry budgets live in stateBoth routers hand-coded attempts >= N and every handler incremented a counterv0.3 route limits, scoped with per — see the word tutor, which deleted its counter
No error pathIf retrieve threw, the graph diedv0.3 retry and onError on every node here that calls out
escalate is a fake handoffIt ended the run with a message; nobody was asked anythingv0.5 approval — though this example still uses the old escalate node, see below
Prompts live in handlers.jsFour of the six nodes are judgement calls with a fixed prompt shapev0.3 llm nodes — but deliberately not used here, see below

Two deliberate choices, now that the features exist:


Calling tools over MCP#

examples/mcp-order-desk/ drives a local MCP server — mcpDb, which exposes Northwind on PostgreSQL over stdio — from inside an LGFlow graph.

These examples were written for v0.1, when MCP was out of scope and the note here read "there is no MCP construct in the DSL, and none is needed". The first half of that stopped being true in 0.23, which added requires.mcp; the second half was always the more interesting claim, and it still holds for what these examples do.

They still run, unchanged. A tool call is something a handler does, the flow only says which step runs next, and the same eight nodes would work against a REST API or a stub. Nothing about 0.23 takes that away — a flow that reaches a server from its own handler code is still an ordinary flow.

What requires.mcp is for is the case these examples do not cover: a server somebody else runs, reached from somewhere else, that an organisation wants to see and control. Declaring it means the platform knows the server is there, knows which of its tools this flow may call, and generates the client instead of asking a model to write one from a description — which is how a machine's path ended up compiled into a deployed project. See requires.mcp in the DSL reference, and docs/mcp-proposal.md for the reasoning.

A library call, a driver, something you wrotea handler, as here
A server reached over stdio or HTTP, that an organisation configuresrequires.mcp
START → resolveCustomer ─┬─ found ──→ loadHistory → planOrder → reviewOrder ─┬─ place ──→ placeOrder ─┬─ done ───→ summarize → END
                         │                              ↑                    ├─ repair ─→ repairPlan ─┘
                         │                              └────────────────────┘                        └─ failed ─┐
                         └─ missing ──────────────────────────────────────────── approve ────────────→ escalate → END

One tool per handler:

MCP toolHandlerWhy
getCustomerDetailsresolveCustomerConfirm the customer exists; get ship-to details
getCustomerOrderloadHistoryThe order to repeat, and the only price list available
addOrderplaceOrderWrite the order, in one transaction

The desk answers questions as well as placing orders. Both need the same two lookups, so the split happens after loadHistory:

loadHistory ─┬─ ask ───→ answerQuestion → END
             └─ order ─→ planOrder → …

Ordering has to be asked for: naming products, or asking to repeat. Anything else is a question, so an ambiguous request gets answered rather than turned into an order nobody asked for. A question opener outranks the repeat wording, so "What was the last order for ALFKI?" reports it while *"Repeat the last order for ALFKI"* plans one.

npm run example:mcp -- "Repeat the last order for ALFKI"

It is a dry run by default — it plans the order and prints it without writing. Pass --commit to actually insert through addOrder. Point it at a different checkout with MCPDB_ENTRY; PG* variables pass through to the server.

Three things this example does that a demo would not:

Tests use a fake tool caller — no child process, no PostgreSQL — which is why createHandlers({ callTool }) is a factory rather than a plain object.

Chat page#

Both MCP agents behind a browser chat, at http://127.0.0.1:5173, with a picker in the header to choose which one answers:

npm run chat

Putting the same question to each is the quickest way to see what the DSL buys you. "What is the customer name for ALFKI?" makes one MCP call under the tool-calling agent — the model saw it only needed the customer row — and two under the order desk, whose graph calls both lookups unconditionally. Each answer is labelled with the agent that produced it, and the panels show the difference.

Type a request and the page streams the graph as it runs — one line per node with what it set and how long it took, and each routing decision as the router makes it, collapsing into a “how it got here” panel once the answer lands. Escalations are styled as escalations rather than as errors, because that is what they are: the agent declining to act, not a failure.

Progress arrives over server-sent events from GET /api/chat, so nothing is buffered until the end — useful when a node is waiting on the database. The page is plain HTML with no build step and no CDN.

Seeing what actually happened#

Each turn carries three collapsible panels:

PanelHolds
N steps — how it got hereevery node and routing decision, with timings
N MCP calls — arguments and resulteach tools/call, the arguments sent, the JSON returned
N LLM calls — prompt and replythe prompt verbatim, the reply, how long it took

Both are streamed as they happen, so the arguments and the prompt are on screen while the call is still in flight. A tools/call that fails shows the error where its result would be — and the turn carries on, because the handler turns that error into a routing decision.

The server wraps the injected callTool and llm rather than reaching into the handlers, so any step that uses either is reported without knowing it is being watched. Both sets also arrive in the final done event, as toolCalls and llmCalls.

With no OPENAI_API_KEY there is no prompt to show: the header says answers: field matcher and that panel stays hidden.

Conversation memory#

Turns share a thread, so this works:

What is the address of ALFKI ? → Obere Str. 57 last orders of him ? → the seven orders, without repeating the id

LGFlow v0.1 does not model persistence, and does not have to. buildGraph forwards unknown options to LangGraph's .compile(), so a checkpointer is wired in without the DSL knowing anything about it — the escape hatch earning its keep:

const graph = buildGraph(State, flow, registry, { checkpointer: new MemorySaver() });
await graph.invoke({ request }, { configurable: { thread_id } });

Each browser tab gets its own thread id (kept in sessionStorage, so a reload continues the conversation and New chat starts a fresh one). Threads never see each other's state.

Saved state has a cost the single-shot version did not: last turn's plan, verdict and escalation are all still there when the next turn starts. So the flow opens with a newTurn node whose only job is to clear the per-turn fields. knownCustomerId is the one field it deliberately leaves alone — that is the memory, and resolveCustomer falls back to it when the request names nobody.

Memory is in-process: restarting the server forgets every conversation. A durable checkpointer swaps in at the same line, with no change to flow.json.

Writes are off unless you ask for them, twice. The server refuses any commit unless it was started with --allow-commit, and the page's commit switch has to be on for that message. It listens on 127.0.0.1 only.

npm run chat -- --allow-commit

From VS Code: press <kbd>F5</kbd> and pick Chat GUI — .vscode/launch.json starts the server and opens the browser as soon as it prints its ready line. Breakpoints in the handlers work, so you can stop inside reviewOrder and step through a routing decision while the page waits. Chat GUI (writes allowed) is the same with --allow-commit.


Who picks the tools#

examples/mcp-tool-calling/ is the same MCP server, the same database, and a deliberately different answer to who decides which tool runs.

START → think ─┬─ call ───→ callTools ─┐
               │              ↑        │   (loop, budget MAX_STEPS)
               │              └────────┘
               ├─ answer ─→ respond → END
               └─ giveUp ──────────→ END
mcp-order-deskmcp-tool-calling
Picks the toolthe flowthe model
Fills the argumentsa regexthe model
Model's jobphrase the answer, at the enddrive the whole thing
Path per questionalways the same two callsvaries
Costone model callone per round trip

The join is toModelTool: MCP advertises each tool with a JSON Schema for its input, which is exactly what function calling wants. No hand-written tool definitions — add a tool to mcpDb and it is available here with no change.

npm run example:tools -- "Who is ALFKI and what did they order most recently?"

A real run: the model asked for both lookups in one pass, read the results, and answered — two model calls, no flow change needed to handle a question nobody anticipated.

think #1: wants getCustomerDetails, getCustomerOrder
callTools: getCustomerDetails({"customerId":"ALFKI"}) ok
callTools: getCustomerOrder({"customerId":"ALFKI"}) ok
think #2: answered without another tool

Read-only by default. A call to addOrder is refused and *the refusal is handed back to the model as the tool result*, so it reports the truth instead of claiming success. Asked to place an order, it looks the customer up, tries the write, is refused, and says so:

think #2: wants addOrder
callTools: refused addOrder - this agent is read-only
think #3: answered without another tool

I attempted to place the order … but the system refused the change: this assistant is running read-only and the order was not created.

--allow-writes lets it through. Tool errors are handed back the same way, so the model can recover — try a wrong id and it will try again with a better one, which is what the loop is for.

The step budget matters more here than anywhere else in this repo: a model that keeps asking for tools would otherwise never stop. routeAction checks the budget before running tools, not after — another round is only worth starting if the model gets to think about the results.

Both examples are the same DSL: nodes, one conditional edge, a loop. The DSL does not know what an agent is, and does not need to.


Doing it all at once#

examples/parallel-review — the v0.6 example. It reviews every section of the README that matches your question, and reviews them simultaneously.

npm run example:parallel -- "conditional edges and routers"
npm run example:parallel -- --serial "conditional edges and routers"
gather -+- review -> spread ==each==> review -> summarize -> END
        +- none ------------------------------> summarize
{ "id": "spread", "type": "fanout", "over": "documents", "as": "document", "to": "review" }

spread has no handler and no edges. How many copies of review run is not in the flow anywhere — the list settles it, at the moment the run reaches it. With a model the timing speaks for itself:

    61ms  -> review (conditional-edges)
   549ms  -> review (validation-rules)
   549ms  -> review (loops)
  3108ms  <- review (validation-rules)
  3382ms  <- review (loops)
  4626ms  <- review (conditional-edges)

Three started before any finished, and they finished out of order. One after another the same work takes about eleven seconds.

Two details that matter more than the speed: