Symphonix Documentation · Core 0.61.0

The API reference

The programmatic surface: what each module exports and what it throws.

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/api.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.

Every export, and what it takes.

import {
  buildGraph,
  buildStateGraph,
  validateFlow,
  assertValidFlow,
  loadFlow,
  loadFlowSync,
  LGFlowError,
  LGFlowValidationError,
  LGFlowRoutingError,
  DSL_VERSION,
  SUPPORTED_VERSIONS,
  // What a flow needs before its code can be written, and what it would be
  checkReadiness,
  isReady,
  // The state schema behind a flow, when you want it yourself
  buildStateSchema,
  resolveReducer,
  REDUCERS,
  CHANNEL_TYPES,
  // Views - what a run shows while it runs (0.11)
  VIEW_TYPES,
  actionsFor,
  createViewTracker,
  renderView,
  viewChannels,
  // Edges, by name
  isConditionalEdge,
  CONDITIONAL,
  NORMAL,
  RESERVED_IDS,
  // Whose flow this is: the system's own, or the person's (0.17)
  FLOW_KINDS,
  SYSTEM_KIND,
  isSystemFlow,
} from "lgflow";

Sixteen more entry points ship beside it: lgflow/serve (serveAgent, the chat page and its stream - and, from monitoring 0.16, the five things a board's chat window asks for on the socket the agent dialled out on: talk for a turn, chatPage for the composed page, describe, conversations and conversation. Four named things rather than an HTTP tunnel, because a tunnel would hand a board /calls and /files/<id> as well), lgflow/health (createHealth, safeDetail), lgflow/journal (openJournal, journalledLlm, journalledCallTool, recordDatabaseCall, withoutPayloads), lgflow/generate (generateProject, planProject), lgflow/testing (runFlowTests, specProblems, describeFailure), lgflow/evals (runFlowEvals, runEvals, previewFlowEvals, previewEvals, evalProblems, pastRuns, scoresByBuild), lgflow/agents (loadAgentRegistry, invokeAgent, agentContract, agentTree), lgflow/triggers (loadTriggers, createTriggerRuntime, triggerProblems, triggerTree, triggerFileFor, triggersFor, deliverResult, mapDeliveryBody, asTable), lgflow/socket (openAgentSocket), lgflow/runs (inRun, currentRun, runContext - which run execution is inside, so a journal line can say which), lgflow/monitor (connectToMonitor - whose link also carries reportBuild and, from monitoring 0.15, reportConversations: the pointer to every conversation this agent holds, and never a word of what was said), lgflow/lock (takeLock, heldBy, readLock, alive - one served agent per folder), lgflow/paused (readPause, writePause, clearPause, saidAboutPause - up, and not taking work), lgflow/approving (digestOf, whatToDo, stillTheSameThing - who decides an approval, and whether it is still about the same operation), lgflow/waiting (openWait, WaitingStore, canWaitOnDisk, waitPath - where a run that has stopped for a person waits, so it is still there after a restart), lgflow/holding (openHolding, Holding, HeldInMemory - the note saying a run is waiting on somebody else's decision, kept beside the run so the agent can ask again after a restart), lgflow/env (loadEnv, readEnvFile, applyEnv - .env, read again when it changes), lgflow/packages (buildPackage, scanPackage, verifyPackageAt, importPackage, instantiate, installedPackages, signPackage, verifySignature, PACKAGE_KINDS, SCOPES - the solution package format as one surface, because LGFlow owns what a flow may say and so owns what a package may contain. Nothing in it deploys, generates, executes, installs or activates anything: it reads, validates, digests and signs, and the four gates into the library are docs/reference/package-reference.md), lgflow/subscription (createSubscription, useSubscription, mayCallTool, refusalFor, nothingSwitchedOn, installedSubscription, FROM_BOARD, FROM_MEMORY, NO_BOARD - which of its MCP tools an agent is switched on for today. The flow is the upper bound and the board is the switch, and the three-way rule is here in one place rather than at each call site: no board, the declaration; a board that has spoken, what it said; a board that never spoke, nothing, because nobody has decided must never be read as everything. docs/mcp-subscription.md is the design, and nothing here is compiled into a generated project - compiling it in is exactly what made a redeploy necessary, and a switch that needs a rebuild is not a switch), lgflow/ask-mcp (askMcpForTools, WITHIN_MS - asking an MCP server what tools it has, which is not the board's job: a board holds no credential for any server, so it cannot ask one, and what it shows instead is the union of what its connected agents declare - empty for a server nobody is running, which is every server somebody has just registered. The only authority on what a server exposes is the server), lgflow/text (amountIn, amountsIn - reading an amount out of text a person wrote, in the engine and never in a generated handler. Measured on 2026-10-18: three of three fresh generations of one handler misread an ordinary amount, all three with the same (?:,\d{3})* shape that allows zero groups, so 6000 matched as 600. A model asked to read money from a description writes a regex; this is the same fix as src/generate/mcp.js, one level down), lgflow/secrets (secretsNamed, secretsIn, withoutSecrets, sweptOf, SECRET_ENOUGH, REDACTED - what a deployment knows is a secret, removed exactly. Two lists, and which one is a mistake depends on where the text is going: secretsNamed(names, env) reads a named, finite set - the variables .env.example asks for and the lines .env gave - and is the list for anything that keeps what a person said; secretsIn(env) takes every value in an environment and is for a short error detail going to another machine, where eating a word costs nothing. Row 0.4 is what happens when the second is used for the first: an agent's own hostname is NAME on Linux, so a stored conversation read "I am running on <redacted>" while the live stream read the truth), lgflow/transcript (openTranscript, noTranscript, keepForFrom, turnWritten - the conversation, kept across a restart; read for one thread, conversations for the pointer a board may have, and TRANSCRIPT_DAYS for how long. A served agent answers the same two questions over HTTP at GET /conversations and GET /conversation?thread=, both behind the door and both filtered to whoever is in - never to the thread the caller named. secrets is required: neither "everything this process inherited" nor "nothing" is a safe thing to assume on a caller's behalf, so one of them has to be said) and lgflow/reference (referenceTopic, which reads the DSL reference at run time). A generated project may import any of them: it vendors LGFlow, so what LGFlow already does is there to be used rather than written again.

openAgentSocket(server, options) — v0.21#

Opens /ws on a server that is already serving, so an agent can be watched and driven. The chat page on the same machine and a monitor somewhere else speak the same vocabulary through it — hello, run, ok, error, pong out; hello, fire, stop, ping in — and onCommand answers both. Nothing is queued: a message sent while nobody is listening is gone, which is what keeps a live view from quietly becoming a message broker.

connectToMonitor(options) — v0.21, monitoring 0.3 (0.4 today)#

The other direction, and the reason it exists: a monitor cannot learn that an agent is not running by connecting to it, so the agent dials out, registers by its flow id, and the socket closing is the report that cannot be forgotten. It pings and rebuilds the link when nothing pongs — a half-open connection gives neither a close nor an error, and without this an agent talks into a dead pipe until somebody restarts it. No url, no connection, no behaviour change.

It registers with protocol (MONITOR_PROTOCOL, currently "0.6") and the capabilities it really has: the six every agent of this protocol has — runs, live-runs, delegation, delegation-cycle (0.4), users (0.5) and pairing (0.6) — plus anything passed as capabilities from OPTIONAL_CAPABILITIES, today health and pause. Optional means optional: an agent whose deployment turned the checks off does not claim health, and a monitor asks the capability rather than comparing version numbers.

link.howToIdentify() asks which door the board wants, link.pair({ thread }) gets a code to show when it wants that one, and onPaired is how the answer comes back minutes later (0.6).

link.verify({ who, secret }, { within }) is the one message that goes the other way: an agent with no list of people asks the board who somebody is, and gets back the canonical address and name. A board that cannot be reached is { ok: false, why: "no board" } — a refusal, never a pass — and so is silence after VERIFY_WITHIN_MS. See docs/guides/monitoring.md.

delegation-cycle is not optional — nothing about a deployment can turn it off — and is announced all the same, because a board cannot read it off the number. A rule about circular delegation reads the failures an agent reports; an older agent reports none, and reading that silence as no circles here would be the one answer a rule must never give by accident.

createHealth(options?) / safeDetail(error, options?) — monitoring 0.3#

Whether this agent can do its job, as distinct from whether it is running. A ping proves the process is up and proves nothing about the work; these run the agent's own checks and shape what they found:

import { createHealth } from "lgflow/health";

const health = createHealth();            // every 60s, 5s per check
health.register("postgresql", async () => { await pool.query("select 1"); });
health.start((report) => monitor.report(report));

A check returns nothing when it is fine, throws or returns { status: "error", error } when it is not, and { status: "unknown" } when it could not tell. A round reports ok, degraded or unknown with a line per check. It never overlaps itself, a check that hangs is reported as an error at five seconds, and one that throws turns its own line red and nothing else — a health subsystem that could stop an agent would be worse than none.

safeDetail is what keeps a credential out of a report that leaves the machine: every value in this process's environment long enough to be a secret, then a URL's path, query and credentials, then anything shaped like a key or named like a password. It is applied to everything a check says.

withoutPayloads(entry) — v0.21#

One journal entry with the data taken out: what ran, when, how long and what went wrong, without what it was given or what it got back. A journal holds the schema an agent was handed, the rows it read, the mail it answered; sending that somewhere else is a decision rather than a convenience, so this is what travels unless a deployment sets MONITOR_DETAIL=full. What was removed is named in withheld, because a reader has to be able to tell "there was nothing" from "there was something and you are not being shown it".

digestOf(value) / whatToDo(answer) / stillTheSameThing(decided, now) / accountOf(value)#

lgflow/approving: the agent's half of an approval an organisation decides.

whatToDo turns a monitor's answer into card or wait. It is a function rather than a condition inside serve.js because the distinction it draws is the whole feature and the easy mistake — reading silence as *nothing governs this* — is one character of difference. wait is the fallthrough, so an answer nobody has anticipated lands there rather than on the card, which is the one that opens the door.

digestOf is a canonical hash of the operation being approved: keys sorted at every depth, a missing key and an undefined one the same thing. From 0.28 it covers the account as well as what was shown, because the case is the reason somebody said yes.

It is taken once and carried, not recomputed. The agent hashes what it is about to put in front of a person, keeps it in its holding note, and sends it with the question; the monitor stores it on the request and hands it back with the decision, and stillTheSameThing compares the two — approve €5,000 and the resumed agent must not perform €50,000. *(This paragraph said "computes it again when the decision comes back" until 2026-10-18, which describes a stronger guard than the one that exists: a recomputed digest would also catch state that moved while the run waited. What it catches is a decision that does not belong to this operation.)* Both ends of that comparison are this function; a monitor stores the string and never computes one, because a canonical form implemented twice in two projects is two implementations that agree until the day they do not.

A decision carrying no digest is accepted, which is not a hole: it is what a monitor sends for a request raised before the agent was sending one, and refusing those would strand every approval in flight across an upgrade.

accountOf (0.28) reads the channel an approval's why names and returns the case as one string, or null. A string channel is trimmed; a list channel is joined with newlines, which is how a flow writes one line per step. Anything that is not words, and anything empty, is null — and nothing is drawn for null, because a heading over blank space reads as *there was no reasoning*.

serveAgent takes whoDecides (ask the monitor) and hangs decide on the server (take the answer). The generated run.js wires both; an agent given neither behaves exactly as every agent before 0.26.7.

openHolding(checkpointer) / server.reAsk() — 0.26.8#

lgflow/holding: the note saying *this conversation is waiting on a decision from elsewhere*, and the question asked again.

openHolding returns a Holding over the checkpointer's own database when that checkpointer is durable, and a HeldInMemory otherwise - the note follows the run, because a note that outlasts the run it is about is something to ask the organisation about for ever on behalf of a conversation that cannot be resumed. Map-shaped (has, get, set, delete) plus all().

server.decide takes say: "yes" | "no" | "cancelled" - the third from 0.26.9 and protocol 0.10. A cancellation ends the run instead of resuming it: no onReject, no decision in the journal, and the note kept as a tombstone so nothing can answer it afterwards. The digest is not checked for one, because nothing is being performed and a run nobody can end is worse than one ended under a changed operation.

server.reAsk() asks the monitor again about everything being held, and resumes nothing - only decide does that. The generated run.js calls it from onState when the link comes up, which covers both a start and a reconnection. There is no new message in MONITOR_PROTOCOL: the same approval is sent again, and a monitor's raise is idempotent per run, so the same request comes back rather than a second one.

Until 0.26.8 the note was in memory, which meant three things at once: a decision could not reach a run the agent had restarted into, a run that stopped while the monitor was down was registered nowhere and asked about never, and the guard that refuses a governed approval in the chat went quiet after a restart - so the organisation's rule could be stepped over by restarting the agent. It returns counts (asked, governed, unreachable, loose) rather than throwing, and says on stdout when a rule has gone out from under a run that is still waiting.

openWait(folder, options?) / WaitingStore / canWaitOnDisk() — 0.26.6#

Where a run that has stopped for a person waits. openWait returns a LangGraph checkpointer: a WaitingStore over node:sqlite at <folder>/.lgflow/waiting.db where that is possible, and a MemorySaver where it is not — and it says which on stdout, every time an agent starts, because the two look identical right up until somebody restarts the process.

Pass it to serveAgent (the generated run.js does) or to buildGraph as checkpointer. A host embedding an agent with createAgent() gets a MemorySaver unless it passes one, which is the same answer as before this version: the folder is the caller's to name.

It never throws. A Node without node:sqlite, a folder that cannot be written, a file that is not a database — each one takes away durability and nothing else, because an agent that refuses to start over it would be a worse answer than 0.26.5, which started and lost runs quietly.

What it stores is runs, which for a chat is what was said, so the folder is already in .dockerignore and the file in the generated .gitignore. A thread nobody has touched for KEEP_DAYS (30) is forgotten when the store opens — by the whole thread, never by the checkpoint, because a run is a chain and half a chain is worse than none.

projectOf(folder) / createProject(folder, options?) / projectTouched(folder, options?) — 0.29.2#

lgflow/project. What a folder is called, and who has worked in it: .lgflow/project.json, beside the lock and the journal.

projectOf always answers. A folder nobody has named is still a project, called what the folder is called, with every field nobody filled null and adopted: true so a screen can tell the two apart. It never writes — opening a folder to look at it must leave it alone, or a checkout becomes something that cannot be opened read-only.

createProject writes the file and refuses a folder that already has one, naming it: creating over a project would lose the one copy of facts that live nowhere else. projectTouched moves lastUpdatedBy / lastUpdatedAt and leaves the created pair alone; call it when a flow is saved and never when one is deployed.

by is whoever the caller has been told it is, and null otherwise. It is never the OS account and never a placeholder: a studio with no board knows nobody, and a field that invents an author is worse than an empty one.

Why these are not in flow.json, where identity (0.21) already keeps name, createdBy and lastUpdatedBy: the identity topic says what it left out and why — a deploy rewriting the flow changes the flow's hash, which is what the deploy compares to decide whether the flow changed. A project's dates are the same shape of fact. .lgflow/ is hashed by nobody. And not in manifest.json either, which is the deploy's record of generation and is rewritten in full every time.

A key this version has never heard of is kept through a write, because the same folder is opened by studios of different ages — which is the whole reason the identity lives in the folder rather than in a list beside one studio.

triggerFileFor(flowPath) / triggersFor(flowPath) — v0.21#

Which triggers.json governs a flow: <flow>.triggers.json by name, or the plain one when it says it is about this flow. Deploy and the studio both call it, so the file a person edited beside their flow is the file the deployed project ends up with.

deliverResult(trigger, outputs, options) / mapDeliveryBody(...) / asTable(value) — v0.21#

Where a triggered run's answer goes. mapDeliveryBody fills the body with the same fillValues the input mapping uses — one template language, not two — and asTable lays a result out in columns, because Slack, Teams and Discord render mrkdwn and would show an HTML table as literal characters. deliverResult posts it and never throws: a report that could not be sent is not a schedule that has stopped working.

runFlowEvals(path, options) — v0.18#

Reads an *.eval.json, runs the flow it names for real once per case, and asks a judge how well each answer met each criterion. Answers with data; nothing is printed.

Returns { file, flow, total, completed, summary, thresholds?, evals }. Each case carries name, status (scored, agent_error or judge_error), score, criteria (each { score, reason }), metrics, checks, output, usage, events, and unusedMocks when the case declared a mock the run never reached. The run itself carries at, build - hashOf(flow), the same hash a deploy writes into .lgflow/manifest.json - and recorded when a record was kept. summary carries the mean score, the mean per criterion, averageLatencyMs, and usage with the agent's tokens and the judge's kept apart.

Throws EvalFileError when the file cannot be run at all — as opposed to scoring badly — with every problem in error.problems.

runEvals(spec, options) — v0.18#

The same, for a suite already parsed. Takes options.file and options.dir so flow, handlers and dataset resolve the way they would from a file on disk.

evalProblems(spec, flow?) — v0.18#

Everything wrong with an eval file, said at once, as strings a person can act on. Empty when there is nothing wrong. Called for you by both runners before a single model call is made.

previewFlowEvals(path) / previewEvals(spec, options) — v0.18#

What a run would do, without doing any of it. Runs every check runEvals runs — the same prepareEvals, so a preview that accepts a suite is a promise about the run and not a second opinion — and stops before a model is built. Takes no model, needs no key, costs nothing.

Returns { file, flow, total, calls, judge, thresholds?, evals }, where calls is { runs, judge } and judge is { chars, estimatedInputTokens }. Each previewed case carries:

Throws EvalFileError exactly as a run would, which is the point: the refusals a run would make cost nothing to find here.

What a run leaves behind — v0.18#

A test is free and repeatable; an eval is neither. So a run of a suite on disk writes two things in a .lgflow folder beside it:

result.recorded is where the run file went. pastRuns(file) lists a suite's kept runs, newest first, as { name, path }.

scoresByBuild(file, { keep = 20 }) — v0.21 — reads those runs and groups them by the build they scored, newest first, as { build, at, score, passed, cases, completed, scored, run }. A build scored more than once carries its latest score and the number of times it was scored; a run saved before a result carried a build is left out. This is what lets did this get worse, and with what change be answered, and what lets the monitor say whether the worse one is the one deployed. openEvalJournal(file, on?) opens both for a suite and is what the runner calls; anything embedding runEvals wants options.journal rather than this.

Both are best-effort and neither can fail a run. options.journal: false turns them off, and a spec handed straight to runEvals in memory keeps nothing — it has nowhere of its own to keep it.

unusedMocks(mock, events) — v0.18#

From lgflow/testing. Which of a case's declared mocks the run never reached, as { tools?, reads? }. A mock that never fires is the quietest way for a run to go wrong — the route that would have used it was never taken, and everything after that point is about something else — so every eval result reports it. It is not a failure, and it is never silent.

loadAgentRegistry(path) / createAgentRegistry(spec, options?) — v0.19#

The project's agents, by name. loadAgentRegistry reads a registry file; createAgentRegistry takes the same spec already in memory, with options.dir for relative paths and options.flows to supply the flows directly instead of reading them.

Each flow is read with loadFlow and checked with validateFlow — the compiler's own rules, not a second copy — and then checked for a usable contract: an agent with no description cannot be chosen, and one with no outputs is not worth choosing.

Returns an AgentRegistry:

Lookups are a Map.get on a name already matched against AGENT_NAME, so nothing a model produces can become a path or a prototype key.

agentContract(flow, options?) — v0.19#

A flow's contract, as an agent sees it: { name?, description, inputs, outputs }, where each input and output is { name, type?, description? } read from the flow's own state. Nothing is declared twice — this is the v0.7 contract.

contractProblems(flow) says why a flow could not be delegated to. describeAgents(contracts) writes a list of them for a model to choose between.

checkAgentInput(contract, input, where?) holds a call to the contract before the child is built: every declared input must be there and hold something, and anything undeclared is refused by name. declaredOutputs(contract, childState, where?) takes what may cross back - the declared outputs the child actually wrote, and nothing else - and refuses when it wrote none of them. Both throw LGFlowAgentError; both are what an agent node calls for you, and are exported because a host delegating from its own code needs the same boundary.

AGENT_CODES is every code LGFlowAgentError.code can carry, in one list, so a caller switching on them cannot invent one: UNKNOWN_AGENT, DUPLICATE_AGENT, INVALID_REGISTRY, INVALID_REGISTRY_ENTRY, AGENT_FLOW_UNREADABLE, AGENT_FLOW_INVALID, AGENT_NOT_PERMITTED, AGENT_DECISION_INVALID, AGENT_INPUT_INVALID, AGENT_OUTPUT_INVALID, AGENT_FAILED, AGENT_RECURSION. See Many agents for when each is raised.

invokeAgent(options) — v0.19#

Run one registered agent. { registry, name, input, allowed, compile, options, node, parentAgent, parentRunId, chain }; returns { agent, output, runId, invocationId, durationMs }.

Checks happen in this order and none is skipped: the name is a name, it is in allowed (when there is an allow-list), it is registered, it is not already in chain, and the input fits the contract. Then the child is compiled with the given compile (buildGraph) and run, and only its declared outputs come back. Every refusal is an LGFlowAgentError with a code — see Many agents for the table.

An agent node in a flow calls this; it is exported because a host that wants to delegate from its own code should not have to write a flow to do it.

agentTree(events) / agentsIn(tree) / describeAgentTree(tree) — v0.19#

Turn a flat list of trace events back into the run's shape. agentTree returns { agent, runId, steps }, where each step is either { type: "node", id } or { type: "agent", agent, node, runId, parentRunId, invocationId, ok, durationMs, error?, steps }. agentsIn flattens the invocations; describeAgentTree writes the tree out for a terminal. A single-flow trace reads as a tree of one.

loadTriggers(path) / loadTriggerFlow(spec, dir) — v0.20#

loadTriggers reads a triggers.json and returns { file, dir, spec }. It only reads and parses: checking is triggerProblems, and the runtime does it before starting anything, so a caller that only wants to look at a file - an editor, a listing - is not made to satisfy rules it is not about to act on.

loadTriggerFlow reads the flow the file names, relative to it. This is the only place a flow is chosen. By the time an occurrence exists the flow has already been read, which is what stops an external payload naming one.

Both throw LGFlowTriggerError with a code: TRIGGER_FILE_UNREADABLE, TRIGGER_FLOW_UNREADABLE, INVALID_TRIGGER_FILE.

triggerProblems(spec, flow?) — v0.20#

Everything wrong with a trigger file, as { code, message, path } — the path being the thing at fault, so an editor can show it where it belongs. Empty when there is nothing wrong.

The one implementation. The runtime refuses a file with it, and LGStudio serves this same module to the browser, exactly as it does validate.js. Given flow as well it also checks the mapping against the flow's own contract.

createTriggerRuntime(spec, options) — v0.20#

options.run is required and is what invoking the flow means - the runtime never builds a graph. Also takes flow (so the mapping is checked against its inputs before anything starts), trace (the same tracer the run gets), onError and file. Refuses a file triggerProblems rejects.

The runtime:

serveTriggers(runtime, options?) — v0.20#

Brings up an HTTP listener for the runtime's webhooks: { port, url, server, close() }. port 0 asks the machine for a free one; host is loopback unless you say otherwise, and defaults to TRIGGER_BIND from the environment — its own variable, never implied by the chat's BIND, because this endpoint has no Host check and whatever reaches it starts the flow. otherwise lets it sit in front of a server that already does something else. Non-JSON bodies are answered 400 and start nothing; a run that throws is answered 500 and the listener stays up.

nextRun(schedule, options?) / describeCron / cronProblem — v0.20#

Five-field cron, over real instants in an IANA time zone. nextRun answers null for a schedule that never comes round — and for a time zone this machine does not know, rather than throwing the RangeError Intl raises: both mean "I cannot tell you when", and the callers that read a file nobody has checked yet — an editor, a panel — must not die on one. What is wrong with the zone is triggerProblems' answer, and the runtime refuses the file over it before anything is armed. describeCron writes the common shapes in words - *Every weekday at 09:30* - and hands anything else back as it was. cronProblem says why one cannot be read, naming the field.

triggerTree(events) / describeTriggerTree(tree) — v0.20#

{ triggers, steps }: the v0.19 agent tree with the reason the run exists on top of it. Each trigger carries triggerId, triggerType, runId, triggeredAt, ok, durationMs, its input, and eventName or method/path. A run nothing started reads as the run it is.

mapTriggerInput(trigger, occurrence?, at?) — v0.20#

What one occurrence makes of a trigger's input, filled by the DSL's own template rules. triggerContext builds what a mapping may read, which is short and never includes request headers.

buildGraph(State, flow, registry, options?)#

Validates the flow, builds the StateGraph and compiles it.

Throws LGFlowValidationError before touching LangGraph if the flow is invalid.

buildStateGraph(State, flow, registry)#

The same, without compiling. Use it as an escape hatch when you need a LangGraph feature that LGFlow v0.1 does not model: build from the DSL, add what is missing in native LangGraph, then compile yourself.

const builder = buildStateGraph(State, flow, registry);
builder.addNode("approval", humanApproval);
const graph = builder.compile({ checkpointer });

validateFlow(flow, registry?)#

Returns an array of { code, message, path } — empty when the flow is valid. Omit the registry to check structure only, which lets you lint a flow before its handlers exist.

assertValidFlow(flow, registry?)#

Throws LGFlowValidationError on the first problem. The full list is on .errors, and .format() renders all of them.

loadFlow(path) / loadFlowSync(path)#

Read and parse a .json flow. Does not validate — pass the result to buildGraph or validateFlow.

saysOf(node) / kindOf(node) — v0.21#

In src/kinds.js, and not an entry point - it is not re-exported from the package root and ./kinds is not in exports, so a consumer cannot import it. LGStudio reads it: the file is served to its own page the way the validator is. It is listed here because what it decides is visible on the canvas, not because anything outside this repository can call it. The same is true of reaches.js and catalogue.js below.

kindOf is the node's type, with handler for the one that is not spelled out. saysOf is the one line it can say about itself - llm, tool: chargeCard, each items, form: 2 fields, agent: sql | documents, a child flow's filename, a handler's name.

Every line is read off a field the flow declares, so what a reader is told is what the compiler will do; a node whose field is missing or empty says ?, because tool: ? is a question somebody can answer and a tidier label naming no tool is one they would never think to ask about. It is short because it is drawn inside a box - the longer answer is the node's own description.

It exists to be the only copy. LGStudio's canvas is handed it as layoutFlow(flow, { says }). It was written when there were two drawings - a Mermaid export beside the canvas - and each had worked it out alone and each was incomplete in its own direction: the export named six of the ten kinds, the canvas named three, and a tool node and a plain handler were the same box with one word in it. The export went in 0.26.3; this stayed, because one place that decides what a node says is right with one drawing too, and cheaper than finding out again with the next.

mcpReaches(flow) — 0.26.3#

In src/reaches.js, and not an entry point either. Which nodes reach which MCP server, as [server, nodeIds] pairs, with servers nothing reaches left out. checkReadiness asks it before a deploy and LGStudio's canvas asks it to draw the servers beside the graph.

A node reaches a server by naming it, or by naming one of the tools the flow declared on it. A tools node reaches every server the flow declares, because it runs whatever is queued and what is queued is decided during the run: the declaration is the ceiling. It lived inside the Mermaid export until 0.26.3, which is how a function with two real callers came to be covered only through the shape of a third thing's output.

nodeSummary(node) — v0.21#

What a step does, in the terms the flow declares: { kind, says, reads, writes, does, undescribed, tip }.

readsevery channel it reads — the {{channels}} in its prompt, its tool arguments at any depth, a child flow's input, plus the ones a kind names outright (fanout.over, tools.calls). LGFlow's own internal channels are left out: they are machinery, not this flow's state
writesits output. For a flow node that is the values of the mapping — the keys name the child's channels, which are the child's
doesthe node's description, word for word
undescribeda handler with no description
tipthe three as text, for a tooltip, with a long description clipped

reads and writes are null for a handler, never []. Its code is arbitrary — that is what a handler is — so nothing here can know which channels it touches. Nothing can say and it touches nothing are different answers and only one of them is true.

does is quoted and never written. It would be easy to have a model compose a sentence per node at the moment somebody hovers one, and it would be wrong: description is what generateProject writes that node's code from, so a second description is a second account of the same step, drifting from the one the code came from — and a reader looking at one would be told the other.

undescribed is about handlers alone, for the same reason. On a typed node a description is a note; on a handler it is the whole specification, so one with none is a step whose code was generated from nothing. LGStudio marks those on the diagram.

CATALOGUE / catalogueTool(id) — v0.21#

In src/catalogue.js: the tools somebody can start from, as data. Each entry has an id, a title and a summary for the person choosing, a tool (name, description, inputs — the four fields anybody would otherwise type), and env, the names its deployment will need.

catalogueTool(id) hands back { tool, env } as a copy, because it goes into somebody's flow and is theirs to edit from that moment on. catalogueEntry(id) hands back the catalogue's own frozen entry instead, for anything that wants to show one rather than add it.

It is not a new way to call anything. There were already four — a tool node, a tools node, a read node and an agent — and a fifth would have been a fifth thing to learn, to validate, to draw and to keep agreeing with the other four. An entry is a tool: the generator writes its code from the description, the unit is checked before it is kept, regenerating does not lose an edit. The only new thing is that you can find it.

The prose stays where the model reads it. description carries the awkward specifics that separate code that works from code that looks right — *the reply has no url*, never add response_format — and topic: recipes keeps its own framing. test/catalogue.test.js holds the two together: an entry the reference never mentions fails, which is the same binding features.md has to the engine and the DSL reference has to the compiler.

channelTouches(flow, channel) — v0.21#

Which steps are at either end of one channel: { reads, writes, unknown }, all node ids. A channel is the one thing in a flow with no shape on a diagram - it is not a box and not an arrow, it is what the arrows are about - so the only way to see one is to name the steps that touch it.

unknown is what makes it honest. Every handler goes there, whatever the channel: its code is arbitrary, so nothing outside it can say. Leaving them out would let a reader conclude they do not touch it, which is a stronger claim than anything here is entitled to make; putting them in reads would be a guess. LGStudio lights the two known ends on the canvas and says the count of the third in words, where it cannot be mistaken for a mark on the drawing.