The API reference
The programmatic surface: what each module exports and what it throws.
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.
path— the eval file.options.llm— the model the flow runs on,(prompt, label, { onUsage }) => Promise<string>, the same shapebuildGraphtakes.options.judge— the model that scores, in that same shape. May be the same one. The eval file never names a provider: which model judges is runtime configuration.options.concurrency— how many cases run at once. Two by default, on purpose: a dataset launched all at once is a large bill in one second.options.journal—falsekeeps no record of the run. True by default; see What a run leaves behind below.
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:
name,input,metrics,reference.criteria— each withname,description,weight?,chars, andprompt: the exact prompt that judge would be sent, withPREVIEW_ANSWERstanding where the answer will go. It is not an invented answer, for the same reason an eval may not mock one.mocks—{ tools, reads, answers, values }: which are stood in for by name, and the values themselves, clipped.realTools— tools the flow declares that this case does not stand in for. They are called for real.story— the case as what it does:[{ title, lines }], in the order *what is asked · what stands in for the real thing · what is taken as true · what the judge is asked · what must hold whatever the score is*. Built from the case bystoryForinsrc/evals/story.js; no model writes it.
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:
journal.jsonl— every model call, agent and judge, written by the sameopenJournala deployed agent uses. Each line carriescaseandrolealongside the prompt and the reply.runs/<suite>-<when>.json— the whole structured result. The newest twenty per suite are kept.
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:
names— every registered name, in registration order.size,has(name)— a name that is not a name is not one it has.get(name)—{ name, address, flow, contract }, or throwsUNKNOWN_AGENT.contracts(only?)— every contract, or only the named ones in that order.toJSON()— the registry as plain data.
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:
start()arms the schedules; events and webhooks need nothing started.stop()clears every timer and releases every listener. Timers areunref'd, so an armed schedule never keeps a finished process alive.emit(name, data)— one result per trigger that listened, each independent.receive({ method, path, body, query })—{ status, body }to answer with.fire(id, occurrence?)— one occurrence now, whatever kind. What Run Now uses; it changes no schedule.preview(id, occurrence?)— what it would send, without sending it.nextRunOf(id),routes,triggers,active,find(id),started.
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.
State— a LangGraph state schema (Annotation.Root({ ... })).flow— the LGFlow definition.registry— handler/router name → function map.options.compile—falsereturns the uncompiled builder.options.trace— see Tracing below.options.llm—llm(prompt, label, { onUsage }), needed by llm nodes (0.3).options.callTool—callTool(name, args), needed by tool and tools nodes (0.4). It is wrapped so a Google access token inrequires.envrenews itself.options.files— where file channels keep their bytes (0.9).options.agents— the project'sAgentRegistry, needed byagentnodes (0.19).- Every other property is forwarded to LangGraph's
.compile().
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 }.
reads | every 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 |
writes | its output. For a flow node that is the values of the mapping — the keys name the child's channels, which are the child's |
does | the node's description, word for word |
undescribed | a handler with no description |
tip | the 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.