Monitoring
What the board knows, how it learns it, and what it will not store.
It is the same file the platform's own tests are written against — LGFlow/docs/guides/monitoring.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.
What a monitor needs to know about an LGFlow agent: how to find one, how to talk to it, and what it will and will not tell you.
LGFlow DSL 0.33 what a flow.json may contain
monitoring protocol 0.19 what an agent will tell a monitor, and ask it*Both numbers in that box said something else until 2026-11-05: 0.24 and
monitoring 0.8, which were current when this guide was written and then sat
there for nine protocol versions while sections were added underneath them.
A reader's first fact was false, and a reader integrating a board would have
built to it. The title carried the same two numbers and now carries none — the
version a thing arrived in belongs on the section that describes it, which
is what every ## below already does. The box is now held by
test/answered-nothing.test.js, so a protocol bump that leaves it behind
fails rather than drifting.*
Two numbers, two clocks. A flow's version says what the language may
contain; what an agent reports about itself is a different concern and moves on
its own. 0.8 moved the protocol and left the language alone, and it is the
plainest demonstration of the split there has been: a board now decides which
of an agent's MCP tools are switched on and tells it while it runs, and
flow.json says exactly what it said before. 0.7 did the same - a chat
carrying somebody to Microsoft and back - and between them they are the reason
neither number is ever read for the other.
The subscription is monitoring 0.8's whole subject, and it is documented
with its design in docs/mcp-subscription.md and its message in
LGStudioMonitor's docs/protocol.md. The short of it: requires.mcp is the
ceiling, the board is the switch, and mcp-subscription is a named
capability - an agent older than 0.8 is never sent one and goes on obeying
its flow, which is neither a yes nor a no and must not be read as either.
Both moved at 0.22/0.6 - requires.users is a language change, and
asking a board who somebody is is a protocol one - which is the clearest reason
yet to keep reading them apart rather than either off the other. An agent
announces what it can do; nobody infers it from the DSL version.
The shape of things#
C:\Dev\Apps\LGFlow the compiler. A flow is JSON; it generates a project.
C:\Dev\Apps\LGStudio the visual editor, on 127.0.0.1:5174.
C:\Dev\Apps\<name> a deployed agent: a folder that runs on its own.A deployed agent is an ordinary Node project. node run.js --serve starts it;
that is also what LGStudio's Play button runs.
Four files say four different things, and keeping them apart is the spine of the design:
flow.json what the agent does
triggers.json what starts it - a clock, an event, an HTTP request
registry.json which agents it may delegate to, by name
.lgflow/ what the deployment knows: a manifest, and the journalA trigger never goes in flow.json: a flow has to stay runnable by hand, and a
graph describing its own alarm clock would not be portable between the places
it might be started from.
Finding an agent#
A flow carries its own identity from 0.21:
{ "version": "0.21", "id": "10488b0a-6fb5-41e1-b4ae-3b1ebc3b877a", "name": "SQL Engine" }Key everything on id. It is generated once, when the flow is first saved
to a file, and never changes - not on rename, not on move, not on deploy. A name
can be changed and is not unique. A port is per run. A flow saved to a different
file gets a new id, because a copy is another agent and two of them sharing one
id is one row on a board with whichever reported last winning.
An agent whose flow.json has no id cannot be keyed on at all - it says
hello with nothing to match, and it is a deployment made before the flow was
ever saved with one. Say so rather than dropping it: a monitor that ignores such
a connection shows an agent that runs perfectly and can never appear on the
board, with nothing anywhere explaining why.
Where a flow was deployed is not in flow.json, deliberately: a deploy would
have to write it, and what a deploy writes cannot live inside what a deploy
hashes, or every deploy leaves the flow looking like it needs another one. The
deployment records it instead, in <project>/.lgflow/manifest.json:
{ "flow": "<hash of flow.json>", "generatedAt": "2026-10-01T01:10:57.292Z" }LGStudio matches a flow to its folder by that hash - deployed.js
findDeployment, or POST /api/deployed with { path, flow }.
What a starting agent announces#
On stdout, one JSON line each, in this order:
{"lgflow":"serving","port":58149}
{"lgflow":"socket","path":"/ws"}
{"lgflow":"triggers","armed":[{"id":"schedule","type":"schedule","next":"...","schedule":"*/1 * * * *"}]}The cron travels with the announcement, not only the time worked out from it: a time announced once is wrong the moment the trigger fires, so whoever is watching recomputes rather than counting down.
One served agent per folder (v0.21)#
Serving takes the folder. run.js --serve - and npm start, which serves the
chat - writes .lgflow/running.json beside the journal naming the process that
holds it, and refuses to start when another live process already holds it:
This agent is already being served from this folder by pid 29328 on port 51914
since 2026-10-03T08:20:07.000Z. Stop it first, or - if it is really gone -
delete .lgflow
unning.json.Not tidiness. Two copies of one deployment both work, which is what makes this
worth preventing: each arms what is in triggers.json, so an hourly job fires
twice an hour from two processes that cannot see one another, and nothing else
in the system notices. The port is ephemeral, so there is no address in use to
fail on; a monitor keys its row on the flow id, so two copies show as one row;
and whatever started the second - a restarted runner, the studio, a terminal -
is not what started the first, so no process holds both facts.
Held is checked, never assumed: a lock naming a pid that is not running is taken over, because the one way a file like this fails is by outliving what it protected. A machine that loses power owes nobody an explanation afterwards. A lock written on another machine is treated as held, since this one cannot ask whether that process is alive, and the refusal names the file to delete.
A single run - node run.js '{"q":"..."}' - never takes the lock. Trying an
agent out while it serves is a normal thing to do; serving it twice is not.
.env, read again when it changes (v0.21)#
Restarting a deployed agent is expensive in a way that is easy to overlook: the live stream a board is watching drops, whatever was in flight ends with no outcome, and the monitor forgets everything it had seen that process do. The journal survives — it is a file on that machine — but the picture does not.
So .env is watched, and a change is applied where it can be:
| What arrives late | What happens |
|---|---|
OPENAI_API_KEY / ANTHROPIC_API_KEY | the next model call uses it. The key is read when a call is made, not when the client was built — it used to be a default parameter, evaluated once at startup and held in a closure for the life of the process |
MONITOR_URL | the agent dials the monitor there and then, builds its health checks if it had none, and appears on the board. An agent deployed without one used to be impossible to watch without starting it over |
| A corrected value | taken, if the file is what set it last time |
| A line removed | the value is withdrawn, if the file is what set it last time. Without this there was no way to take a key away without a restart — which is the one cost this whole arrangement exists to avoid |
| A value somebody else set | left alone — see below |
Which value wins. A variable already set in the real environment beats the
file: an agent may be started by a runner, which hands on the monitor's own
environment, and a deployment with a perfectly good .env must not start
without its key because something passed the name along empty. On a later read
the same rule holds, with one addition — the file may change a value it gave
last time, because that is its own value and editing it is exactly what
somebody just did. A name the file stops saying is withdrawn by the same
rule read the other way round: it was the file's own value, and deleting the
line is also editing it. The file never takes away what it did not give.
One implementation of that rule (lgflow/env), used by startup and by every
read after it.
Names are said on the console; values never are. Every line of that file is a candidate secret.
What a re-read cannot reach: a port already bound, and a health check
registered at startup — adding one to health.js still needs the agent
restarted. Everything read at the moment it is used is reached.
One process per flow id (v0.21)#
.lgflow/running.json stops a second copy being served from the same
folder. Nothing in a folder can stop a copy of that folder, a second
deployment, or the same deployment on another machine - and those are the
cases that hurt most, because both copies work: each arms what is in
triggers.json, so everything hourly happens twice, and a monitor keys its row
on the flow id, so the two of them show as one.
So the monitor owns the flow id. When a process registers an id that is already being served, the monitor asks the incumbent - with a WebSocket ping, below the application, so no agreement from either agent is needed - and:
- the incumbent answers → the newcomer is sent
{ "type": "duplicate", "of": { pid, host, cwd, since } }; - the incumbent says nothing within a second → it is not serving anything, whatever the connection list says, and the newcomer takes over.
That second case is the whole difficulty. An agent that was restarted leaves a socket that has not finished closing, and treating that as a duplicate would stand down every legitimate restart.
An agent told it is a duplicate pauses itself rather than exiting: dying would throw away whatever it was in the middle of and leave nothing to look at. It writes the pause with the other process named —
paused by "the monitor" - pid 1234 on PROD-A (C:\Dev\Apps\sqlengine) is
already serving this flow id— so it is up, refusing at every door, its clocks down, visible on the board with the reason, and a person decides which copy to keep.
And it writes it in its journal, as a state entry. Standing down was
announced to stdout and nowhere else, and a terminal that has been closed
says nothing: a copy that stood down at three in the morning left an agent
that was up, paused and silent about why. The journal is where this agent's
history is kept, and another process got here first is history.
An agent with no MONITOR_URL is not covered: nothing is watching it to notice.
The collection server is on the board too — and it is not an agent#
A RAG Collection Manager reads MONITOR_URL as well, for a different
reason, and the same variable serves: it already asks the board for its
collections over it, and now it also dials in and says it is answering.
Nothing to configure — registered as part of the deployment means nobody
types an address.
It appears on the fleet screen, beside the agents and not among them, in a section of its own. That is worth knowing before looking for it:
- it is listed as Server in words, with what each collection holds — paragraphs, the embedding model the index was really built with, how the documents were split, when, and what the last build could not read;
- the board cannot start or stop it. That was weighed and rejected: it runs where the documents are, which may not be where the board is, the catalogue can hold several, and the dependency is one-way on purpose so a board restarting never takes an index down with it;
- and it has no Health of its own here. Whether a collection answers a search is on Settings → Knowledge, where it is measured.
A board that is down costs a row on a screen and not one answered question. Every failure to reach it is one line on stdout, said once however long it lasts, and a search never touches it.
Paused: up, and not taking work (v0.21)#
Between running and stopped there was no word for the state people actually
wanted: the process alive, what it had started still going, and nothing new
getting in. .lgflow/paused.json, beside the lock and the journal, is that
state — and it is the agent's, not the monitor's, because only the agent can
answer every door: its own chat page, LGStudio's Play, its clocks, and another
agent delegating to it.
{ "since": "2026-10-03T09:14:00.000Z", "by": "the monitor on PROD-A", "why": "spend" }While it is there:
- every
POST /runand/resumeis refused with 503 and a sentence — *"This agent is paused since … It is up and finishing what it had started; it is not taking new work."* 503 and never 500: nothing has gone wrong, it is current, and it can be undone, which is what that code means; - nothing is armed from
triggers.json, andfireis refused — firing a trigger by hand is new work with a different name on it; - reading still works. The journal, the flow and health all answer, because those are how somebody decides whether to let it work again.
It is read on every request rather than once at startup, so a pause lands on the next thing that knocks rather than on the next restart; and because it is a file in the folder, an agent paused because of money that crashed in the night comes back paused rather than armed.
Who can pause it#
Three ways in, and the file is the state in all of them:
{ type: "pause", by, why }, answered by the samehandleCommandas every other word — so it arrives from a monitor or from anything on the agent's own/wssocket, LGStudio included. There is no monitor-only vocabulary here, and nothing in the agent asks who is calling.- The file, written into the folder by hand or by a script. Every door
refuses from the next request, because the pause is read per request; the
clock follows within a moment, because the agent watches
.lgflow/for it. - A restart into a folder that already holds one: it comes up paused, and says so when it registers.
resume is the mirror of the first and deleting the file is the mirror of the
second. The command is immediate and is what the board uses; the watcher is the
backstop that makes the file mean what it says — without it a hand-written pause
closed every door and left the schedule firing until a restart, because a
trigger calls the flow directly and never passes through the door that refuses.
Both words are announced as the pause capability — never inferred from the
protocol number, which an agent deployed before this knows perfectly well
without knowing the word.
The socket#
This is the socket an agent listens on, for anything that wants to watch it. The other direction - an agent dialling out to a monitor, which is what
connectToMonitorandMONITOR_URLdo - is a different vocabulary, and it is specified in LGStudioMonitor'sdocs/protocol.mdalong with the runner and dashboard sockets.
ws://<agent>/ws, on the same server as the chat. The agent listens; whoever
wants to watch connects to it.
the agent says hello · run · node · triggers · ok · error · pong
the agent hears hello · fire · stop · pingEvery message carries at, an ISO timestamp.
hello - who this is. Sent on connection and on request. Ask for it: a client that attaches its listener after the open event - the obvious way to write one outside a browser - misses the greeting otherwise.
{ "type": "hello", "agent": { "id": "...", "name": "SQL Engine", "version": "0.21",
"inputs": ["question","schema_file"], "outputs": ["query_result","query_error"] },
"pid": 16836, "since": "2026-10-01T07:12:00.000Z" }run - a run, as it happens. startedBy is "trigger" for one nobody asked
for. phase is enter or exit; an exit carries ok, durationMs, and
either outputs or error.
ok / error - the answer to a command, and never both. An agent that cannot do what it was asked says so: a monitor told nothing assumes it worked, and "I stopped it" when nothing stopped is the worst answer available.
fire { type: "fire", trigger: "<id>" } - run that trigger now.
stop { type: "stop" } - stop the clocks. The agent keeps serving.
Nothing is queued. A message sent while nobody is listening is gone - this is a live view, not a broker. What was missed is in the journal.
What a run is (monitoring 0.2)#
Every journal entry says which execution it belongs to:
runId the logical run this line is part of
parentRunId the run that delegated to it, when it is a child
startedBy what started the whole thing: manual, schedule, event, webhook
triggerId which trigger, when one did
agent which delegated agent's work this isBefore monitoring 0.2 there was no such thing. Entries carried thread, which is the
conversation - one thread holds every run anybody ever asked for in it, so
two runs a second apart were indistinguishable and grouping by it merged
unrelated work. Only triggered runs had an id, and it never reached the
journal, so even those could not be reassembled.
One id per logical run, never a second for the same execution. A chat turn mints one; a triggered run reuses the id the trigger runtime already stamps its own entries with; a delegated agent gets its own with the parent named.
The id follows execution rather than being passed through it (src/runs.js,
AsyncLocalStorage), which is what makes the two hard cases right: runs in
flight at once keep separate stores and cannot merge, and a delegated agent's
calls become the child's and the parent's again when it returns.
Delegation is in-process. invokeAgent compiles and runs the child inside
the parent, and a deploy copies the children it may call into the same project.
A parent and its children write to one journal, so cross-agent correlation is
parentRunId within it - there is no second agent on a board to join to.
A delegated run reports both ends from monitoring 0.3:
{ "type": "run", "phase": "enter", "runId": "<child>", "parentRunId": "<parent>",
"agent": "sql" }
{ "type": "run", "phase": "exit", "runId": "<child>", "parentRunId": "<parent>",
"agent": "sql", "ok": true, "outcome": "done", "durationMs": 120 }Before that, the only thing saying a delegation had happened was the runId on
the entries the child wrote: anything watching opened the child's run on its
first entry and never heard it end, so every delegation looked like a run
still in flight, for ever. startedBy is deliberately absent from these -
what started a delegated run is whatever started the whole execution, which
inRun hands down.
Two things in the same release made the first level of delegation correct at all, and both had looked right because every test began by saying what the parent run was:
parentRunIdnow falls back to the run in flight when the compile options do not carry one. They only do when the parent is itself a delegated agent, so a child of a child nested and the first level reported no parent - which made one execution look like two, counted under both.- the served journal context no longer stamps the turn's
runIdon every entry. An explicit context beats the run in flight by design, and inside a delegation that wrote the child's model calls down under the parent's run.
A run also reports itself as it happens:
{ "type": "run", "phase": "enter", "runId": "6f2c…", "startedBy": "manual" }
{ "type": "run", "phase": "exit", "runId": "6f2c…", "ok": true,
"outcome": "done", "durationMs": 1840 }outcome is done, error, stopped, or waiting: question / form /
approval. A question is not a failure: the run is waiting for a person.
A failed exit says what sort of failure it was and where it stopped, and - for the one failure that is about the shape of the project rather than the luck of a run - the circle delegation went round (0.4):
{ "type": "run", "phase": "exit", "runId": "6f2c…", "ok": false,
"outcome": "error", "durationMs": 90, "error": "…",
"failure": "delegation", "node": "delegate",
"chain": ["orders", "billing", "orders"] }chain appears for delegation and nothing else. Agent names are what a flow
declares and a registry resolves - structure, like the node id beside them - so
they travel; what the agent was working on does not, unless the deployment says
MONITOR_DETAIL=full.
Whether it can do its job (monitoring 0.3)#
A socket that answers a ping proves the process is up, the connection is up and the event loop can answer. It proves nothing about the work: a revoked key and a database refusing connections both look perfectly green.
So an agent checks its own dependencies and reports what it found:
{ "type": "health", "at": "…", "status": "degraded",
"checks": [ { "name": "openai", "status": "ok", "durationMs": 84 },
{ "name": "postgresql", "status": "error", "durationMs": 1002,
"error": "connect ECONNREFUSED 127.0.0.1:5432" } ] }the agent ok · degraded · unknown
one check ok · error · unknownAny check failing makes the agent degraded. Nothing known at all is
unknown, which is not ok and not error: not asked is not refused. The
state and the health are two questions - RUNNING and DEGRADED at once is
ordinary, and neither may overwrite the other.
The agent owns its checks, because it is the only thing that has them. A monitor that knew how to test PostgreSQL would need the deployment's own settings, and would have to reach out to every agent - the one direction this architecture does not go.
Declaring one#
Not in flow.json. A flow says what the agent does; whether a database
answered this minute is operational, it changes between two runs of the same
flow, and putting it in the DSL would make every health decision a flow edit, a
validation pass and a deploy.
src/health.js in this package is the runtime:
import { createHealth } from "lgflow/health";
const health = createHealth(); // once a minute, five seconds each
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. It gets five seconds, it never overlaps itself, and a check that
throws turns one line red and nothing else: a health subsystem that could stop
an agent would be worse than no health subsystem.
A generated project wires two sources, and only two:
derived the model provider, really reached (GET /v1/models with the
deployment's key - no tokens spent, and a key that is merely
present proves nothing); each delivery variable a trigger needs,
by presence; registry.json, when the flow delegates.
health.js beside run.js. The generator does not write it and a deploy does
not overwrite it, which is exactly why that is where it lives:
export default function health({ register }) { ... }HEALTH=off in the agent's .env stops the checks. Without MONITOR_URL
there are no checks at all - they are real requests against somebody else's
service, and an agent nobody is watching should not be making them once a
minute for ever.
What a report may never contain#
API keys, passwords, bearer tokens, connection strings with credentials, authentication headers, secret environment values. This is the easiest place in a system to leak one, because the credential is usually why it failed.
safeDetail removes every value in this process's environment that is long
enough to be a secret, wherever it appears and whatever it is called; then a
URL's path, query and credentials, keeping only scheme and host, because a path
can be the whole secret; then anything shaped like a key or named like a
password; then it clips. The monitor sanitizes again at its end.
Saying so#
{ "protocol": "0.8",
"capabilities": ["runs", "live-runs", "delegation", "delegation-cycle", "users", "pairing", "requires", "signin", "mcp-subscription", "health"] }health is optional within the protocol: a deployment can turn the checks off, and
an agent with nothing worth checking has none. So it is announced rather than
implied, and a monitor asks the capability. Unavailable is not unhealthy.
delegation-cycle is not optional - nothing about a deployment can turn it off
- and is announced all the same, because a monitor cannot read it off the
protocol number. A board's rule about circular delegation reads the failures an agent reports; an older agent reports none, and a rule that took that silence for no circles here would be giving the one answer a rule must never give by accident.
Asking who somebody is (monitoring 0.5)#
A flow may declare "requires": { "users": true } (DSL 0.22): this agent serves
named people, and somebody arriving at it says who they are first. The agent
keeps no list of people — it asks the board, over the link it already has.
const said = await link.verify({ who: "Shmuel@Example.com", secret });
// { ok: true, who: "shmuel@example.com", name: "Shmuel Tal", why: "ok" }This is the one message that goes up: everywhere else the agent reports and a monitor asks. Four things about it are deliberate, and the first is why the rest exist — the agent is not where this decision belongs. A list of people on every deployed agent is a list that goes stale separately in every folder, and a password checked in a dozen places is a password kept in a dozen places.
- The secret passes through and is kept nowhere. It lives for the length of one message. Nothing writes it to a journal, a log, an alert or a trace, at either end, and the answer carries a sentence rather than anything that was sent.
whocomes back canonical. Not as it was typed: two spellings of one address are one person, and that is the spelling to record a run under.- No board is a refusal, never a pass —
{ ok: false, why: "no board" }when the socket is not open, and the same afterVERIFY_WITHIN_MS(8s) with nothing back, so nobody is left at a form that has frozen. An agent that opened the door whenever the link was down would have a door anybody could open by taking the link down. This is also why a flow withrequires.userscannot usefully run without a monitor, and says so rather than starting and refusing everyone. - Answers are matched by
ask, like every other correlated frame. Two people signing in at once share one socket, and an answer that found the wrong question is somebody let in as somebody else.
Announced as the users capability, never read off the protocol number: an
agent deployed before this existed speaks its protocol perfectly well and has
never heard the word, and a board that assumed otherwise would promise a
question nothing will answer.
When there is no password to ask for (monitoring 0.6, 0.7)#
Some boards prove people at Microsoft, through a browser. Then verify has
nothing to carry: the password is typed over there, and belongs nowhere near an
agent. So the agent asks which door to use.
const how = await link.howToIdentify(); // { identify: "password" | "microsoft" | "pairing" }microsoft (0.7) sends the person there and gets them back — here. The chat
shows one button; the browser goes to Microsoft, returns to the board's
registered callback, and the board redirects it straight back to this chat
with a one-use ticket. The board is a 302 in the middle that nobody sees, and
the only page anybody looks at that is not the chat is Microsoft's own.
const made = await link.startSignIn({ back: `http://${host}/identify/done` });
// …the browser goes to made.url and comes back to /identify/done?ticket=…
const said = await link.claimSignIn({ ticket }); // { ok, who, name }
const session = server.letIn(said); // the token, handed to that browserback is this agent's own address, taken from the request the browser arrived
on — an agent's port is assigned when it binds and differs every run, so there
is nothing to write down. The board checks it against what this agent
registered and refuses anything else.
The password form stays beside that button, because a password the board keeps works in every mode — that is what a break-glass account is for. An agent that offered only Microsoft locked every one of them out of every chat.
pairing is the fallback now, not the way. It is what a board answers to an
agent that has not announced signin — one deployed before 0.7, which does not
serve the route a redirect would land on. Then the agent shows a code and goes
quiet, and the board pushes the answer back down the socket it already has:
const shown = await link.pair({ thread }); // { ok, code: "K7PQ-M4TN", url, within }
connectToMonitor({ …, onPaired: ({ thread, who, name }) => server.letIn({ thread, who, name }) })A code shown on one screen and confirmed on another worked, and nobody could follow it: you press sign in on a chat and are asked, in a product you did not open, about a number you never saw.
howToIdentify is asked every time, never remembered. It is a setting on a
screen over there; an agent that cached it would be collecting a password
nobody can check on the morning somebody moved the board to Microsoft. No board
answers password, which is harmless: the form appears, the attempt is refused
for want of a board, and nobody is let in either way.
letIn takes a name that has already been proved, and answers with a session
token. There is nothing in the agent to check the name against — that is the
whole point of the arrangement — so it does exactly what the board said.
The token is what the browser then carries, in x-lgflow-session. It is the
person, so it opens every conversation they start; a thread is a
conversation and opens none. That was the other way round until 0.7, which made
a thread a credential — see docs/security-review.md. Coming back from
Microsoft the token rides in the URL fragment, which a browser sends
nowhere; the pairing fallback has the page swap the code it was shown.
The board's half (one use, ten minutes, which agent is named on the confirm
screen, and why a code is not a credential) is in docs/protocol.md there.
LGStudioMonitor's docs/protocol.md has the message shapes and the board's half
of the rules — including the one refusal sentence and why there is only one.
The door, as somebody meets it#
POST /identify { thread, who, secret } -> { ok, who, name }
POST /identify { thread, from } -> the same person, a new conversation/identify is its own path rather than a field on /run, for one reason: a
run's input is journalled, previewed, and sent to everyone watching, and a
password must never be near a path that does any of that. Nothing on this one
writes, logs, echoes or keeps what arrives.
Who is identified is per conversation, keyed on the thread, and the
conversation is locked after twenty minutes with nothing happening
(IDENTIFIED_FOR_MS). Counted from last use rather than from signing in: a
conversation somebody is in the middle of should not stop in the middle of it,
and one left open on a screen in an empty room should. Pressing New chat
carries the name over with from — the same person, still here, and asking for
a password every few minutes is how people are taught to type one without
looking.
What is behind the door, and what is not:
/run, /resume | behind it — this is the work |
/files (upload) | behind it |
/calls | behind it: every prompt and tool result, which is more of what the agent did than the conversation shows |
/conversations | behind it, and filtered to whoever is in — the pointer only: which conversation, whose, when it last moved, how many turns, and not a word of what was said |
/conversation?thread= | behind it, and not by the thread the caller named: a thread id is not a credential, so a conversation whose turns are not the asker's is 404 rather than 403 — that one exists but is not yours says the id was a real one |
/flow | answers the title, the description and requires.users and nothing else. The channels, the nodes and the suggestions are the design of the thing, and not something to hand to somebody who has not said who they are |
/health | open. A door that also hid whether there was a building would make a working agent look like a missing one |
A thread id is itself enough to go on with a conversation that is signed in —
here and at /run. That is the same bargain the whole server already makes: it
refuses any Host but this machine's, and answers nothing that did not come
from here. It binds loopback too, unless the deployment says otherwise with
BIND — a container has to, since its own loopback reaches nobody — and that
widens the bind and nothing else: the Host check is not configurable, so the
way in is still something in front that addresses the agent as localhost. What
a wide bind gives up is the one part no header replaces, which is why it is off
unless asked for and says so on stdout when it is not.
What kind of failure it was (monitoring 0.2)#
llm · tool · timeout · agent · delegation · validation · trigger · runtime · unknownDecided in src/failures.js, from the error's class and code and which call
it came out of - never from its message. The words are written for a person and
are improved whenever somebody has a better sentence; a category that moves when
a sentence is reworded is one nobody can count. The category is written on the
failed journal entry and on the run's exit event.
delegation is apart from agent (0.4). invokeAgent has refused a
circular delegation since v0.19 - step four of its order, before the child is
built - so nothing has ever looped for ever. What it did not do was say so in a
way anybody watching could act on: the run arrived as agent, which is also
what a delegated desk throwing looks like, and so one occurrence was counted
against a failure streak along with everything else.
They are different things to go and look at. A desk that threw is the child's problem and may work next time; a flow that can delegate round a circle is wrong by construction and will take that route again. That is also why a circle now travels up as itself rather than being wrapped as *the agent "x" failed* at each level on its way out: no one level is at fault - every agent in it delegated exactly as its own flow says to.
The journal#
<project>/.lgflow/journal.jsonl, one JSON object per line, appended. Kinds:
llm, tool, turn, trigger, question, approval, form, state. A
triggered run writes two lines, enter and exit. state is the one kind that
is not about a run: it is something the agent became - standing down because
another copy of the same flow id got to the monitor first, which was announced
to stdout alone until a closed terminal proved that was nowhere. This is the
durable record; the socket is not.
The conversation, and the pointer to it (monitoring 0.15)#
<project>/.lgflow/transcript.jsonl is the other durable record, and it is a
different one from the journal: the journal says what the agent did — every
prompt, every tool result — and this says what was said, one line per
turn. Two stores on purpose. A journal is capped by lines and read by a board;
a conversation is content, kept per conversation and for as long as
TRANSCRIPT_DAYS says.
What travels to a board is the pointer and never the text:
{ "type": "conversations", "id": "<flow id>",
"conversations": [{ "thread": "a7f1…", "who": "dana@example.com",
"started": "2026-10-27T15:59:00.000Z", "at": "2026-10-28T09:12:00.000Z",
"turns": 14 }] }link.reportConversations(list) sends it, taking exactly what
transcript.conversations() gives. Every report is the whole list and
replaces what the board held, so there is one meaning — *this is what I hold
now* — rather than a count the board arrived at itself and could not
reconcile after a restart. The generated project sends it at every start, at
every reconnection, and after a turn, debounced to at most one report every
two seconds.
To the monitor only. The generated project does not put this on the
agent's own /ws, which checks Origin and Host and not who anybody is: a
browser that has not signed in would otherwise be handed every conversation's
owner and timing, which is precisely what GET /conversations filters.
The capability is conversations, and it is optional — announced only by
a deployment that keeps any. An agent with TRANSCRIPT_DAYS=0 never says the
word, because a board told it would read the empty list as *asked, and there
are none*, and about somebody who talks to that agent daily that is a
confident wrong answer rather than an absent one.
Being talked to through a board (monitoring 0.16)#
The centralised chat window lists every agent a person may use and draws the conversation beside it. What it talks to is this board, and what the board talks to is the socket this agent dialled out on — because there are two walls between a board and an agent and neither is a bug:
BINDis loopback unless a deployment says otherwise, so an agent on somebody's laptop is reachable from nothing at all; and- the chat's
Hostcheck is not configurable.fromHererefuses every request whoseHostis notlocalhost,127.0.0.1or[::1], so a board proxying toagent.internalis answered Local callers only.
Nothing about either is relaxed to make the window work. Five commands arrive on the socket instead:
talk | a turn — the same drive the page's own door uses, so the pause, the approval-decided-elsewhere refusal, the conversation's history and the transcript are all the ones a turn already meets |
chat-page | the composed page, style and all: the same bytes GET / returns. inWindow says the asker already has a bar, a New chat and a History of its own |
describe | what /flow answers — the flow and the title, page included |
conversations, conversation | what was said, filtered to the person the board named |
who comes from the board and this agent does not check it. It cannot:
an agent has nothing to check a name against, which is the sentence pairing
was built on. What replaces pairing's short code is that there is no code to
guess — the name is read from the window's own session on the board and
carried on a socket this agent chose to open to it. An agent whose flow names
people refuses a talk with no name, in the same words its HTTP door uses.
Four named things, never a tunnel. One command taking a method and a path
would be less code and would hand a board every route this agent has: /calls
is every prompt and every tool result, /files/<id> is what people uploaded.
The capability is chat, and it is named so a board knows before it opens
a pane. An older agent answers no such command: talk, which is honest and
arrives too late — the window would have drawn a pane and failed inside it.
And the agent's own page is unchanged, which test/two-doors.test.js
exists to keep true: a turn through /run works with no board anywhere, and
both doors land in one conversation.
Access taken away, pushed (monitoring 0.17)#
Granting has always worked and needed nothing: this agent holds no list of who may use it and asks the board at every sign-in, so ticking somebody on reaches the very next one.
Taking it away was the half that never arrived. A session is minted here
and whoIsIn is touched on use, so somebody removed from a list went on
talking for as long as they kept talking — twenty minutes from their last
message rather than from the change. Nothing told the agent, so nothing
could.
{ "type": "access", "stop": ["dana@example.com"] }
{ "type": "access", "stop": "everybody" }A name ends that person's sessions and disturbs nobody else. everybody is
what the board sends when the rule changed — an open agent narrowed, or an
agent taken off the board — because the board does not know who is talking
here and must not be told; everybody signs in again and each is answered
correctly.
The twenty minutes stay where they are. IDENTIFIED_FOR_MS is not re-asked
per message for the reasons its own comment gives — *a socket round trip per
line of a chat, and a chat that dies whenever the board restarts* — and a push
costs nothing per message and works while nobody is talking, which is the
case the twenty minutes never covered.
The capability is access-push, named so a board knows before it asks:
an older agent means the change bites when the session runs out, which is a
different sentence from applied and the board says which.
A run that answered nothing says so (monitoring 0.18)#
A graph that reaches END with every output channel empty is a
successful run by every measure there was. The journal records outcome:
"done", a board's ok is outcome === "done", and the agent's drawer read
SUCCESS RATE 100.0% · 2 of 2 finished over a run that told the person in
front of it nothing. It is not a fan-out's defect, though a fan-out over an
empty list is where it was found: anything that ends with no channel written
looks like this.
The fact was on the agent's machine the whole time and could not leave it.
A turn's journal entry carries outputs, and withoutPayloads strips
outputs before anything is sent — naming it in withheld, so a reader can
tell there was nothing from *there was something and you are not being shown
it*. A full answer and an empty one therefore both arrive at a board as
withheld: ["outputs"], and there was no field in which the two differed.
So the field is a boolean, which is the one shape that crosses that line: it says whether anything was written and names nothing that was.
{ "kind": "turn", "outcome": "done", "answered": false,
"withheld": ["input", "outputs", "steps"] }It is measured in src/serve.js where the output channels are in hand — not
off the shortened preview the page is sent, because a fact read from a
shortened copy is a fact about the shortening.
**Empty is three things and all three are not answered. A channel no node
ever wrote, one written with null, and one whose value a person reads as a
blank screen. An empty list or object is not** one of them: [] is a node
saying none, which is an answer, and filing your search found nothing as
this agent told you nothing sends somebody to debug a flow that worked.
The capability is answered, and this is the rule named, never inferred in
its sharpest form. A board reading the ability off the protocol number would
take an older agent's silence for answered: false — *this agent has told
nobody anything* — which is worse than the gap it replaces, because a figure
on a screen is believed and that one is false. An older agent's runs have no
answer to this question, and a board has to keep that as a third state.
What is not built#
- No authentication on the agent's own socket, beyond the loopback the chat
already sits on. The monitor link is separate and takes a token
(
MONITOR_TOKEN); this is aboutws://<agent>/ws, and it is the first thing to settle before an agent is reachable from anywhere else. - No discovery. A monitor must be told where agents are. LGStudio knows: it resolves a flow to its folder, and it starts the process.
- No durable history on the socket. The journal is the record.
- No step that is still running. A model call or a tool call is journalled
when it returns, so nothing can honestly say which call is in flight. What
is live is the run, and - through
parentRunId- which delegated agent it is inside. Anything watching should say so in those words.
An agent does dial out, since v0.21: connectToMonitor and MONITOR_URL.
That is the other direction from the socket above, and it is specified in
LGStudioMonitor's docs/protocol.md along with the runner and dashboard
sockets. A monitor cannot learn that an agent is not running by connecting to
it, which is the whole reason that direction exists.
Conventions worth keeping#
- One implementation, never a copy. LGStudio serves LGFlow's own validator and trigger checker to the browser rather than reimplementing them, so what the editor draws as valid is what the runtime accepts.
- Comments say why, not what.
- Tests assert the reason a thing exists, not its spelling.