The monitoring protocol
What an agent tells the board, and what the board may tell an agent.
It is the same file the platform's own tests are written against — LGStudioMonitor/docs/protocol.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.
Everything that travels between an agent, a runner, a collection server, a dashboard and the monitor: the four sockets, both directions, and the rules the two ends had to agree on.
monitor protocol 0.19 this document
application 0.53.0 lgstudio-monitor
LGFlow DSL 0.33 a flow reads a database by name; this board holds the connection and names it at deployThree numbers on three clocks. The protocol is what the sockets agree on.
The application is this program. The DSL is what a flow.json may contain — an
agent's monitoring behaviour is a different concern, and a monitor that read one
number for both questions would be guessing. 0.53.0 moved neither of
them: a screen reader is told which round an agent is on, the command
palette's keyboard cursor is given a stripe of its own, and two schema-rewind
fixtures stop keeping hand-written lists of what the steps above them added -
nothing the sockets agree on and nothing a flow.json may contain changed by
a word. 0.52.0 moved neither of them either, and it was one colour token
in a stylesheet: the edge that says a thing is a control, moved one rung along
its own derivation so it clears 3:1 on the fourth ground a control sits on.
The move
before those was the protocol's alone - an agent reports what its MCP server
offers (protocol 0.19) - and nothing a flow.json may contain changed then
either, because what a server holds is something an agent learns at runtime
rather than something a flow may declare. The occasion they moved
together is the one worth keeping, because it is the only one and is not a
reason to stop reading them apart: a flow learned to say that it serves
named people (DSL 0.22, requires.users), while an agent and a board learned
to ask who somebody is (protocol 0.5). Either number can move again on its
own, and usually does. *This paragraph said "this release moved two of them"
for as long as it took that to stop being true - protocol 0.5 to 0.18, which
is thirteen of them - so the version each claim is about is now written into
the claim.*
0.19 is released as of 0.50.0, and the paragraph that said otherwise is gone rather than left to rot. For a day this document read *the title says 0.19 while the application still says 0.49.0 — that is this tree and not a release*, which was true and is the state these two numbers are read apart in order to be able to write down. It stops being true at a close, and a sentence that stops being true without anybody editing it is the mistake this whole section warns about one level up. Nothing degraded for an agent deployed against 0.18 and nothing does now: the frame is announced as a named capability, so an agent that says nothing about its server's tools is not read as a server with none.
The README says what the monitor is and why. LGFlow's
docs/guides/monitoring.md says what an agent offers anyone watching it,
over its own socket. This is the part in between, and it is written down because
twice it was not: a command that every dashboard sent and no agent ever answered,
and a folder one end spelled C:\Dev\Apps\x while the other spelled
C:/Dev/Apps/x. Both were agreements nothing recorded.
The shape of it#
┌───────────── the monitor ─────────────┐
agent ─────────▶│ /agent they dial in │
runner ─────────▶│ /runner one per machine │
index ─────────▶│ /collection a collection server │
browser ────────▶│ /dashboard watches, and asks │
└───────────────────────────────────────┘Everything dials in. The monitor connects to nothing. That is not a convenience: a monitor cannot learn that an agent is not running by connecting to it, so agents report and silence is the signal. The same reasoning gives the runner its reason to exist — a stopped agent has no socket, so "Run" has to reach something that is already there.
One HTTP server carries all four, plus the page itself. Default
127.0.0.1:5180.
And the number did not move for the fourth one. MONITOR_PROTOCOL is
what an agent tells a monitor, and a collection server is not an agent:
bumping it here would be reading one of the three numbers for another, in the
document that states the rule. The kind is written down instead. A board that
wants to know whether anything is answering for a collection asks the same
question it always did - is one connected - and gets a yes or a silence.
Nor for 2.7, which added more to this document than anything since the
fourth kind arrived: a journal and a stop on /collection, and
start-collections and kill-collections on /runner. Not one of them is
something an agent says, so by the rule above the number stays where it
is. Said out loud a second time because the temptation is real and the cost
of giving in to it is specific: every capability on this board is named
precisely so that nothing has to read an ability off a version, and a number
that moved for a runner's vocabulary would invite exactly that.
Every message#
JSON, one object per frame. The sender stamps at, an ISO timestamp. An
unparseable frame is ignored, not answered: there is nobody to answer in a
protocol where a malformed message means the sender is already confused.
A frame carrying ask is a question, and the answer carries the same ask
back. See Correlation.
/agent — what a deployed agent says#
The agent end is LGFlow's connectToMonitor(), started by the generated
run.js when MONITOR_URL is set. Without that variable nothing is dialled and
the agent is invisible however it was started — which is the first thing to
check when a row stays grey.
register — the first thing said, and the only one required#
{ "at": "2026-10-01T18:12:00.000Z",
"type": "register",
"agent": { "type": "agent", "id": "bbed7a48-…", "name": "Israeli News",
"version": "0.21", "inputs": [], "outputs": ["headlines"],
"pid": 10364, "port": 50610, "host": "ODETA-PC",
"cwd": "C:\Dev\Apps\israelinews",
"protocol": "0.7",
"capabilities": ["runs", "live-runs", "delegation", "delegation-cycle", "users", "requires",
"pairing", "health", "pause"] } }id is the flow's id, and everything is keyed on it. It is generated when
the flow is first saved and never changes, which is what makes it the only
field a history can be kept against: a name can be changed and a port is per
run.
protocol and capabilities are not the DSL version (monitoring 0.2). A flow's
version says what the language may contain; what an agent will tell a monitor
is a different concern on a different clock, and reading one number for both
questions is how a monitor ends up guessing. They are named rather than
numbered, so the question is "can this one do X" instead of "is this one new
enough":
runs journal entries carry runId/parentRunId/startedBy, so entries gather
into runs and runs into a tree
live-runs a run says when it starts, not only when it is over
delegation a child agent's work is the child's run, with the parent named
delegation-cycle
a run stopped because delegation went round in a circle says so,
as a failure kind of its own and with the circle it went round (0.4)
health it checks its own dependencies and reports what it found (0.3)
users it can ask the board who somebody is, and will not let anybody in
while it cannot ask (0.5)
pairing it can show a code instead, for a board that proves people
somewhere a password cannot travel to (0.6)
signin it can carry somebody to Microsoft and back to its own chat,
so a code never has to be shown (0.7)
requires its registration says whether its flow has a door at all -
`requiresUsers`, true or false (0.7)policies says which approval policies its flow asks for, by name (0.11).
A flow may say approval.needs: "two-eyes" from DSL 0.27 - a name, chosen by
whoever built it, meaning nothing by itself. Only the agent can say which names
its flow asks for; the board has no flow and should not go reading one off
somebody's disk. It is sent as a sorted list of distinct names, and the point
of sending it at registration is that a board can then say *this agent asks
for two-eyes and nothing here is mapped to it* while nobody is waiting on
anything - instead of that being discovered when a run stops.
The same silence as requiresUsers and mcp, and it matters the same way:
[] is this flow asks for no policy, and no field at all is an agent
older than 0.11, which is did not say. A board that read silence as *nothing
missing* would be reassuring somebody about an agent it never asked.
requiresUsers has three answers, and the third is silence. A monitor lets
an organisation say who may use each agent, and that list is enforced at the
agent's door; a flow without requires.users has no door, so anybody who can
reach its port is already past and a list on the board is a list somebody
believes in. The monitor says so on the screen where the list is made - but an
agent older than 0.7 sends no such field, and it did not say must not be read
as no. Telling somebody their list does nothing, when it may be the only thing
standing between a stranger and that agent, is the mistake this field exists to
prevent.
The four above health are true of every agent speaking this protocol. health is
optional within it: a deployment can switch the checks off, and an agent with
nothing worth checking has none - so it is asked of the capability, never
inferred from the number beside it.
delegation-cycle is named for a reason worth stating, because it is not
optional and nothing about a deployment can turn it off. 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. So the rule asks for the
capability and, without it, says the condition is unanswerable rather than
satisfied.
users (0.5) is named for the same reason and used the other way round. An
agent that cannot ask has no way to tell one person from another, so a flow
whose requires.users is set cannot be served by such an agent at all — the
door stays shut and says why, rather than opening because nobody could check.
A board that read the capability off the protocol number would be promising a
question the agent has never heard of.
An older agent sends neither field, which is exactly how a monitor knows it is older. It must degrade and say so rather than fail — nothing about liveness, logs, tokens or commands depends on these.
An agent whose flow.json has no id registers with id: null. It is not
dropped — it becomes a row keyed on no-id:<host>:<cwd> in the state
unidentified, because a connection nobody can account for is the thing a board
most needs to show. It means a deployment made before the flow was ever saved
with an id; the cure is to save the flow and deploy again.
run — a run starting and finishing (monitoring 0.2)#
{ "type": "run", "phase": "enter", "runId": "6f2c…", "startedBy": "manual",
"thread": "9ae9…", "at": "…" }
{ "type": "run", "phase": "exit", "runId": "6f2c…", "startedBy": "manual",
"thread": "9ae9…", "at": "…", "durationMs": 1840,
"outcome": "done", "ok": true }
{ "type": "run", "phase": "exit", "runId": "6f2c…", "at": "…", "durationMs": 90,
"outcome": "error", "ok": false, "error": "…", "failure": "delegation",
"node": "delegate", "chain": ["orders", "billing", "orders"] }A triggered run has reported itself this way since 0.20, with
startedBy: "trigger"; from monitoring 0.2 the runs people ask for do too, so a board
has one shape for both and no gap where the chat is.
outcome is done, error, stopped, or waiting: question / waiting: form
/ waiting: approval. A question is not a failure: the run is waiting for a
person, which is neither success nor error, and a board that called it either
would be wrong about both. ok is true only for done.
A failed exit also carries failure - one of the categories below, decided
by LGFlow where the thrown thing still was, from its class and its code and
never from its message - and node, where it stopped. Neither is a reading
of the sentence in error: the words are written for a person and are reworded
whenever somebody has a better one, and a category that moves when a sentence
moves is a category nobody can count.
chain is there for one failure only, delegation, and it is the circle
delegation went round: ["orders", "billing", "orders"] (0.4). 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.
log — a journal entry, as it is written#
{ "type": "log", "entry": { "id": 41, "kind": "run", "at": "…",
"tokens": { "total": 812 }, "cost": 0.0021,
"withheld": ["input", "outputs"] } }Live, never kept by the monitor. Past 40 entries a second the agent stops sending and reports the gap by number instead:
{ "type": "log", "dropped": 17 }A gap nobody is told about is a log that quietly lies. What was dropped is still in the agent's own journal.
health — what it found out about its dependencies (0.3)#
{ "at": "2026-10-02T17:30:00.000Z",
"type": "health",
"status": "degraded",
"checks": [
{ "name": "openai", "status": "ok", "durationMs": 84, "detail": "openai, key accepted" },
{ "name": "postgresql", "status": "error", "durationMs": 1002, "error": "connect ECONNREFUSED 127.0.0.1:5432" },
{ "name": "mcp-database", "status": "ok", "durationMs": 21 }
] }Unasked, on the agent's own clock, and again the moment it reconnects. See Dependency health below for the whole arrangement.
paused — up, and not taking work (0.3, pause capability)#
{ "type": "paused", "paused": { "since": "2026-10-03T09:14:00.000Z", "by": "the monitor on PROD-A", "why": "spend" } }"paused": null says it is working again. The same object rides on register,
so a monitor that has just started — or just restarted — draws a paused agent as
one, instead of green with an offer to start what is already up.
The agent owns this state, the way it owns its health. It is written in that
agent's folder (.lgflow/paused.json), so it survives the agent restarting —
an agent paused because of money, that crashed at four in the morning and came
back armed, is the failure the whole state exists to prevent. A pause held in
the monitor would be one the agent's own chat page, its own clock and anything
calling it directly would walk straight past.
What a paused agent does: finishes what it had started, refuses every new run with 503 and a sentence, and takes nothing off its clock. What it does not do: hide. The journal, the flow and health all still answer, because those are how somebody decides whether to let it work again. 503 and never 500 — nothing has gone wrong, it is current, and it can be undone, which is exactly what that code means.
A delegated call arrives at the same door and is refused the same way, so the parent run fails with a sentence naming the paused agent rather than with something that looks like a bug in the delegate.
verify — asking the board who somebody is (0.5, users capability)#
The one question that goes up the agent socket. Everywhere else the agent reports and the monitor asks; here the agent has somebody at a form and no list of people to check them against, so it asks over the link it already has.
{ "type": "verify", "ask": "v-7", "who": "Shmuel@Example.com", "secret": "…" }{ "type": "verified", "ask": "v-7", "ok": true,
"who": "shmuel@example.com", "name": "Shmuel Tal", "why": "ok" }who comes back canonical, not as it was typed: two spellings of one
address are one person on the board, and every run is recorded under the one the
board gave back. The agent keeps nothing else — there is no list of people on
the agent's side to go stale, which is the whole reason the question is asked
rather than answered locally.
The answer carries no role: it did until 0.8, and the agent dropped it on arrival the whole time. See the fifth rule below.
Five rules, each of which is the reason a line of code looks the way it does:
- The secret is in the message and nowhere else. Neither end writes it down, neither end echoes it back, and the answer carries a sentence rather than anything that was sent.
- A refusal is one sentence, always the same one — *"That email and password do not match."* Four different sentences would be four facts about who works here, readable by anybody who can reach the form. Which of them it actually was (unknown address, wrong password, locked after five tries for fifteen minutes) stays on the board and is kept there.
- No board is a refusal, never a pass. If the socket is down the agent
answers itself with
{ ok: false, why: "no board" }, 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 be an agent whose door is open to anybody who can take the link down. - A password is refused outright on a link anybody could be reading. The
monitor decides that from the connection — loopback, or TLS — and never from a
header, which is a thing the far end says about itself. Over plain
wsfrom elsewhere on the network the answer is a refusal that says so. - The answer says who, and nothing about what they may do. Both halves of
the question are decided on the board - the password or the directory against
its record, and
chat.useagainst that person's roles - and what crosses is the verdict. The role travelled here until 0.8 and was dropped on arrival the whole time; it is gone rather than merely unused, because a field on a protocol is a thing somebody later reads, and an agent deciding anything from a role would be privileges enforced on a machine this board does not administer, going stale the moment that role is edited on the Roles screen.
Answers are matched by ask like every other correlated frame (see
Correlation), which here is not a nicety: two people signing in at once on
one socket, and an answer that found the wrong question, is somebody let in as
somebody else.
signin / claim — Microsoft, and back to the chat (0.7, signin)#
Where the board proves people through Microsoft, verify has nothing to
carry: the password is typed at Microsoft, in a browser, and belongs nowhere
near an agent. So the agent sends the person there and gets them back.
{ "type": "signin", "ask": "s-5",
"back": "http://127.0.0.1:50610/identify/done" }
{ "type": "signing-in", "ask": "s-5", "ok": true,
"url": "https://login.microsoftonline.com/…/authorize?…" }back is the agent's own address, and the monitor checks it against what that
agent registered: the port must be the one it is serving on, and the host
either the one it reported or a name for this machine. A redirect to an address
a caller named is the open redirect, and the moment after a sign-in is the worst
one to hand somebody to a stranger.
The browser then goes to Microsoft and returns to the monitor's registered
callback — which is a 302 and nothing else. The monitor proves the person,
checks Settings → Users, checks chat.use, checks that agent's own list, mints a
one-use ticket and sends the browser straight back to the chat. Nobody sees a
page that is not the chat's or Microsoft's. One Azure redirect URI covers every
agent, which matters: an agent's port is assigned when it binds and is different
every run, so there is nothing to register per agent.
{ "type": "claim", "ask": "c-6", "ticket": "4f1c…" }
{ "type": "claimed", "ask": "c-6", "ok": true,
"who": "dana@example.com", "name": "Dana Levi" }One swap, and the monitor answers only about a ticket it minted for this agent: a ticket is not a credential and cannot be carried to another chat to collect somebody else's name.
No conversation is named in either message. What is being proved is a
person, and the session the agent mints from the answer opens every
conversation that person starts. These carried a thread until 0.7, which is
what made a thread a credential at the agent's end - see LGFlow's
docs/security-review.md.
how / pair / paired — a code, for an agent that cannot be returned to (0.6)#
{ "type": "how", "ask": "h-3" }
{ "type": "how", "ask": "h-3", "identify": "password" } ← or "microsoft", "pairing"Asked when somebody opens a chat that answers only named people, and asked
every time. The agent cannot work it out: it is a setting on a screen at the
board, it changes while agents are running, and one that had cached it would
collect a password nobody can check on the morning somebody moved the board to
Microsoft. No board is password, which is the harmless answer — the form
appears, the attempt is refused for want of a board, and nobody is let in.
microsoft wherever the agent announced signin; pairing otherwise.
Asked of the capability and never of the protocol number: an agent deployed
before 0.7 does not serve the route a redirect would land on, so a monitor
guessing from the version would be sending people to a 404. pairing is now
the fallback rather than the way — a code shown on one screen and confirmed on
another worked, and nobody could follow it.
The password form is drawn beside microsoft, never instead of it. A
password the board keeps works in every mode, which is what a break-glass
account is for; a chat that offered only Microsoft locked every one of them out.
{ "type": "pair", "ask": "p-4", "thread": "9ae9…" }
{ "type": "paired-code", "ask": "p-4", "code": "K7PQ-M4TN",
"url": "http://127.0.0.1:5180/?pair=K7PQ-M4TN", "within": 600000 }The agent prints that and goes quiet. The person opens the board, signs in the way that board signs people in, and confirms. Minutes later:
{ "type": "paired", "thread": "9ae9…", "code": "K7PQ-M4TN",
"who": "shmuel.tal@tradomatics.com", "name": "Shmuel Tal" }The one message with no ask — nothing asked for it. The question went out
minutes ago and this is the board volunteering the answer when a person finally
got round to it.
The same shape every device with no keyboard uses to sign in to anything, for the same reason: the credential is typed where credentials are typed, and what crosses to the thing that cannot collect it is a name that has already been proved.
- A code is one use, and lasts ten minutes. Guessing one does not let
somebody into a chat — it does the opposite and worse: it would attach *the
guesser's own* name to a stranger's conversation, and the stranger would act
as them. Eight characters from an alphabet with no
0/Oand no1/I, because somebody reads it off one screen and types it into another. - The board says which agent is asking before anybody confirms. Confirming blind is how a name is handed to whatever happened to ask.
- A code is not a credential. It names a waiting conversation, grants nothing on the board, and is useless to anybody who cannot already sign in there.
mcp — which of its tools it is switched on for (0.8)#
Board → agent, with no ask: nothing asked for it. Sent when the agent
registers, before it is asked to do anything, and again every time somebody
changes it on the board.
{ "type": "mcp",
"subscription": {
"crm": { "tools": "every" },
"northwind": { "tools": "listed", "only": ["getCustomerDetails"] } } }The flow is the ceiling; this is the switch. A flow's requires.mcp says
everything the agent could ever call and does not change; this says which of it
is on today. every therefore means every tool its flow declares, never
every tool the server has — a subscription can only narrow, and the board
refuses a tick for a tool the agent has said its flow does not declare.
What the agent does with it is the three-way rule, and the third row is the whole of the lock:
| the agent's situation | what governs |
|---|---|
| no board configured | the flow alone — a developer with no monitor must still be able to work |
| board configured, has spoken | the subscription |
| board configured, never spoken | nothing. Nobody has decided is never everything |
So an agent attached to nothing is sent {} — decided, and the answer is
none — and not left in silence, because silence is what never told looks
like, and the two fail closed for different reasons and with different
sentences. An agent that was told and then lost its board keeps working on what
it remembers, from .lgflow/subscription.json beside its journal, and its
health check says the answer is a remembered one.
mcp-subscription is in the agent's capability list, and a board reads it
there rather than off this number. An agent that does not name it is simply
never told, and goes on obeying its flow exactly as it did before 0.8.
databases — which databases it may question (database-subscription)#
Board → agent, with no ask. The message above is the model in every respect
but one. Sent when the agent registers, and again whenever somebody ticks a
box on that agent's Databases panel.
{ "type": "databases", "subscription": ["northwind"] }A list, because there is no second axis to narrow. The MCP subscription cuts a server down to some of its tools; this cuts nothing below the connection. The two things a flow can do with one — ask it a question, read its schema — are not a permission axis: the schema of a database you may question tells you nothing that questioning it would not. A shape with room for more is one somebody fills in later with a meaning nobody decided.
The three-way rule above applies word for word, and [] is decided, and
the answer is none for exactly the reason {} is.
This one is sent only to an agent that names the capability, and here that
is enforced rather than merely observed — the first time the distinction
has cost anything. LGFlow's client hands a message type it does not recognise
to onCommand, which means do this and answer. mcp is safe to send blind
because that client has had a branch for it since 0.8; this type has a branch
in no released agent, so sending it blind would hand every deployed agent a
command out of nowhere each time somebody ticked a box. An agent that has not
named database-subscription is not told, and the panel says so in the
place where the ticking happens.
approval / approving — who decides this (0.9, approvals capability)#
The second question that goes up this socket, and the only other one:
verify asks who is this person, and this asks whose decision is this.
An agent sends it when a run stops at an approval, before showing anybody
anything.
{ "type": "approval", "ask": "a-4",
"thread": "t-91", "node": "pay", "runId": "r-12",
"question": "Approve this refund?",
"shown": { "amount": 5000 },
"account": "Checked eligibility. Two documents received.",
"exhibits": [
{ "id": "a1.pdf", "channel": "invoice", "name": "invoice.pdf",
"mediaType": "application/pdf", "bytes": 4096, "sha256": "9f2c…" }
],
"digest": "6b86b273ff34fce1…" }{ "type": "approving", "ask": "a-4", "governed": true,
"request": 17, "why": "credit-memo v2" }The human question travels as question, because ask is already the
correlation id on every frame here.
account is 0.12: the agent's own case for this approval, in the words
of the step that reached it - what was checked, what came back, what was
concluded. It goes to the person who will decide, who before this got a figure
and a question while everything that made the figure right stayed in the run.
It is approval.why in the flow, which names the channel holding it; it is
account here because why on the answer below already means *why the board
answered as it did*, and one word for two things is how somebody reads the
wrong one.
An agent older than 0.12 sends none, and a board draws nothing rather than an empty heading: this agent did not say and there was no reasoning must not look the same on a card.
exhibits is 0.13, and it is the other half of the same report: *a
person or an agent checked eligibility, perhaps received supporting
documents from the customer, and concluded the customer is owed it.* The
account is the story; these are what the story is about.
A reference, never the bytes. Each entry is a name, a type, a size, a
SHA-256 and the handle the agent knows the document by. The fingerprint
goes into digest with the fields and the account, so *the document I was
shown* is pinned though the bytes arrive later. A board stores the list and
nothing else — which is how *how long does a board keep a customer's
document* becomes a question with nothing to answer.
An exhibit the agent could not read is sent with "sha256": null rather than
dropped: a card quietly showing one fewer document is evidence going missing
twice, and the null still pins that it was there.
Fetching one is a command, answered by the agent's engine against the approval it is holding — not against its file store:
{ "type": "exhibit", "ask": "a-9", "thread": "t-91", "node": "pay", "id": "a1.pdf" }{ "type": "ok", "of": "exhibit", "ask": "a-9", "name": "invoice.pdf",
"mediaType": "application/pdf", "bytes": 4096, "sha256": "9f2c…", "data": "<base64>" }A file of that agent's that this approval does not carry is refused in the same words as an id that is nonsense: telling the two apart is a way to ask what else is in the store. And an agent that is stopped answers nothing at all — which is the cost of fetching rather than storing, and is said on the card in those words rather than failing quietly.
The capability is named, approval-exhibits, and never read off this
number: a board that guessed would offer an approver an open button on an
agent that has never heard the word, on the one screen where being unable to
see the evidence matters most.
shown carries only the channels the flow chose to show, which is the rule
the chat card has always obeyed: an approval is not a window onto the whole
run. digest is a canonical hash of the operation, sent again when the agent
is resumed — approve €5,000 and the resumed agent must not perform €50,000.
The answer has three shapes, and the third is the one to get right.
| what comes back | what the agent does |
|---|---|
{ "governed": false } | shows its own card, exactly as it has since v0.5 |
{ "governed": true, "request": N } | shows no card and waits — two places to say yes would be two answers to one question |
| nothing at all | waits |
Which is the three-way rule again, and its rows do not say what the MCP ones say:
| the agent's situation | what governs |
|---|---|
| no monitor configured | its own card — a developer with no monitor must still be able to answer their own agent |
| monitor configured, has spoken | the rule the monitor named |
| monitor configured, silent | nobody yet. The run waits |
The third row is neither approved nor rejected, and both wrong answers are worth naming. A card would mean anybody who can take the link down can go round the organisation's rule. A refusal would mean a credit memo declined because a socket dropped. Waiting is only a safe answer from LGFlow 0.26.6, which is when a run that is waiting started surviving the process it waits in.
approvals is in the agent's capability list and a monitor reads it there
rather than off this number. An agent that does not name it is never asked
and never tells, and its approvals are answered where they always were.
approvals-cancel is named separately, and on purpose. An agent built
against 0.9 takes a decision and knows two words for one; taking a
cancellation is a thing it cannot do, so a monitor that inferred it from
approvals - or from this number - would send one and get a refusal it could
do nothing with. Two capabilities for what looks like one feature, because
they moved at different times and an agent in the field is whichever version
it was built against.
decide — and the third word (0.10)#
Monitor → agent, and not a command. Every message in *What the agent
hears* is also reachable on the agent's own /ws, deliberately, so that a
chat page and a monitor cannot get different answers out of one agent. This
one must not be: a decision accepted on /ws would be an approval grantable
by anybody who can reach the agent, with the organisation's rule still
sitting unanswered here. So it is handled beside duplicate and
may-return — the messages that arrive only on the socket the agent dialled
out on.
{ "type": "decide", "request": 17,
"thread": "t-91", "node": "pay", "runId": "r-12",
"say": "yes", "digest": "6b86b273ff34fce1…",
"why": "everybody approved" }{ "type": "decided", "request": 17, "ok": true }say has three words from 0.10, and the third is not a third kind of
signature. yes and no are what somebody decided; cancelled is an
administrator ending a request nobody could answer — an approver who has
left, a group emptied by mistake, a request raised against the wrong rule. The
agent stops the run and must not take the flow's onReject path, because
that is what a person refusing looks like and a journal has to be able to tell
the two apart.
Sent only to an agent that announces approvals-cancel, never worked out
from the protocol number. An agent that knows only yes and no is told
nothing and the request stays undelivered, visibly — because the alternative,
mapping it onto no to get it through, hands that agent a refusal nobody
gave. The word exists so that it does not have to travel in disguise.
The acknowledgement is sent whatever happened, and ok: false carries a
why. A monitor keeps what the organisation decided and *whether the agent
has been told* in two columns precisely so that a resume which threw is
visible as a resume that threw — rather than as an approval that looks
delivered, or one that looks undecided and gets asked all over again.
leaving — a clean exit#
{ "type": "leaving" }Sent on SIGINT/SIGTERM. A killed process never gets to say it, which is why
the socket closing is the report that cannot be forgotten — see States.
ok / error — the answer to a command#
Never both, always carrying the ask of the question. An agent that cannot do
what it was asked says so out loud: a monitor told nothing assumes what it asked
for happened, and "I stopped it" when nothing stopped is the worst answer
available.
What the agent hears#
| Message | What it does |
|---|---|
{ type: "fire", trigger } | Run that trigger now. Refused when nothing is armed. |
{ type: "stop" } | Stop the clocks. The agent keeps serving. |
{ type: "terminate" } | Shut down cleanly. |
{ type: "journal", limit, kind, since } | Read its own journal and answer with the rows. limit is clamped to 1–500. |
{ type: "health" } (0.3) | Run its checks now and answer with the report. Refused by an agent that is not checking anything. |
{ type: "pause", by, why } (0.3) | Stop taking new work, stay up, take the clocks down. Answers with what it wrote. |
{ type: "resume" } (0.3) | Take work again, and arm the triggers from triggers.json as it finds it now. |
{ type: "duplicate", of } | Another process is already serving this flow id. The agent stands down - pauses itself, stops its clocks, refuses at its door - and of says which process got there first. |
{ type: "may-return", of } (0.12, stand-up) | That process has gone and the id is free. The agent may come back, and lifts only the pause it wrote when it stood down. |
duplicate and may-return are two halves of one thing, and the second half
was missing until 0.12. A second copy of a flow is refused registration and
stands itself down, which was always right - and then nothing ever undid it.
The board had refused to register that copy, so it is not in agents and no
command routes to it; it stayed paused for ever while its socket sat open here
for exactly this purpose. The board now remembers those sockets and releases
them the moment the holder's closes.
What the agent does about it is the agent's own: it lifts only a pause whose
by and why are the ones it wrote itself, because a pause a person set is
not the board's to lift, and an agent that stays paused is visible and can be
let go while one that let itself go wrongly is a second copy serving again with
nothing to say it happened. stand-up is announced like every other
capability, so an agent without it is simply never told and stays down - which
is what it did before this existed.
pause and resume are announced as the pause capability, not implied by
the protocol number: an agent deployed before this existed speaks the
protocol perfectly well and has never heard the word. A board that guessed from the version would
offer a button that does nothing. fire is refused while paused — firing a
trigger by hand is new work with a different name on it.
health is the check again a person presses after mending something, so they
do not have to wait out an interval to find out whether it worked. The answer
carries the report without its own type — an answer to a command is an ok,
and letting the report's type land on it would break the correlation every
button depends on.
These are the same ones the agent's own /ws socket takes, handled by one
handleCommand in the generated run.js. A chat page and a monitor asking the
same agent the same thing must not be able to get different answers.
/runner — starting what has no socket#
One runner per machine. npm start brings up a runner for the monitor's own
machine over a real socket to itself — one implementation, one protocol, and an
embedded runner that behaves exactly like a remote one.
What the runner says#
{ "type": "register", "runner": { "host": "ODETA-PC", "pid": 8124, "node": "v24.19.0" } }
{ "type": "started", "folder": "C:\\Dev\\Apps\\israelinews" }
{ "type": "ok", "ask": "ask-3", "of": "start", "folder": "…" }
{ "type": "error", "ask": "ask-3", "of": "start", "error": "…" }started is unasked, and it is what lets the board tell started and silent
from never started — opposite problems that would otherwise share one grey
light.
Folders are compared, not matched. The runner reports what the operating system resolved (
C:\Dev\Apps\x); the registry holds what somebody typed (C:/Dev/Apps/x). The monitor normalises separators, a trailing slash and case before comparing. Skipping that is how an agent the runner had just started was reported as "never seen".
What the runner hears#
| Message | What it does |
|---|---|
{ type: "start", id, folder } | Spawn node run.js --serve --port 0 in that folder, detached. Refuses a folder with no run.js, and one it already started. |
{ type: "kill", folder } | End a process it started. Anything else is refused. |
{ type: "start-collections" } | Spawn the collection server on this machine, detached and with no arguments. Refuses a folder with no collection-server.mjs, and one it already started. |
{ type: "kill-collections" } | End the collection server it started. Anything else is refused. |
{ type: "start-databases" } | Spawn the SQL Connection Manager on this machine, detached and with no arguments. Refuses a folder with no database-server.mjs, and one it already started. |
{ type: "kill-databases" } | End the connection manager it started. Anything else is refused. |
A runner that could kill any process named in a message would be a remote kill switch for the machine rather than for an agent. Agents are spawned detached and outlive the runner, so restarting a runner does not take down what it started.
The four platform messages carry nothing at all, and that is the design.
No folder, no command, no port. The board names *a thing this runner already
knows how to start*, and the runner turns that name into a folder on its own
side: COLLECTION_FOLDER or DATABASE_FOLDER, or the sibling checkout, which
is the manufacturing default for a platform whose three parts ship together. A board that could say
run this folder would be remote code execution wearing a Run button, and the
sentence at the top of runner.js is the contract it would break — *it will
not start anything it was not asked for.*
The port is the deployment's, the way an agent's is: the collection server
reads COLLECTION_PORT from the .env beside it, so the runner spawns it with
no arguments and nothing in this protocol decides where it listens.
The connection manager had to be taught the same thing before Run could
reach it — 2026-10-30. It read a port only from --http, so a runner spawning
it with no arguments produced a process answering over stdio: right for an
MCP client, useless to a board, and main returned before it ever dialled, so
the row would not have appeared either. DATABASE_PORT is COLLECTION_PORT's
sibling and not a second idea. It reads a .env beside it now for the same
reason: the one secret it needs is MONITOR_TOKEN, and a command line is
where a secret ends up in a shell's history and in the output of ps.
/collection — what a collection server says, and what it will do#
A collection server registers, and after that the board knows it is answering because the socket is open. Since 2.7 the socket also carries a journal up and one command down, which is the half the owner asked for: *what does it give me to see the Collection Server in the monitor if I cannot control it and cannot even see its logs?*
{ "type": "register", "server": { "name": "collections", "host": "ODETA-PC", "serves": "http", "capabilities": ["collections", "journal", "stop", "spend", "readings"], "bound": "127.0.0.1:7501" } }
{ "type": "journal", "level": "warn", "said": "built unicell: 180 paragraphs, 1 could not be read: scan.pdf" }
{ "type": "log", "agent": "bbed7a48-…", "entry": { "id": "…", "at": "…", "label": "judge for unicell", "model": "gpt-5", "tokens": { "input": 120, "output": 3, "total": 123 } } }
{ "type": "ok", "ask": "ask-7", "of": "stop" }What it hears#
| Message | What it does |
|---|---|
{ type: "stop" } | Close its port, tell the board, and exit 0 — only if it claimed stop. |
It answers before it has gone, deliberately, exactly as an agent's
terminate does: the board is waiting for an ok on the socket that is
about to close, so exiting inside the ask would make every successful stop
look like it did not answer — the one answer a button like this must never
give.
A name it was not built for is refused with a sentence, never ignored: a board whose button got no answer at all shows It did not answer, which is what a wedged server looks like, and this server does not do that is a different fact that the person pressing it needs.
The name is looked up with
hasOwn, not within. The trust on this socket runs backwards from everywhere else here — the server proves who it is withMONITOR_TOKEN, and the board proves nothing — so whatever answers at the address a deployment dials is what can ask. On a plain object,{ "type": "constructor" }finds a function on the prototype.
The journal#
level is one of info, warn, error, and the board replaces anything
else with info: it decides a class name on the board's own page, which is
not a server's to choose. said is clamped to 2,000 characters, because a
socket is as much the network as an HTTP body is.
What is never in one: no key, no webhook path, no password — the same rule as an agent's journal, which is read by a board — and for this server also no question and no passage. A question is somebody's and a passage is a document's. What belongs there is what was done and what it cost: a build started, a count, the name of a file a build could not read, an error.
Live only, and the board says so. Nothing is replayed and nothing is numbered, so a line said while the board was down is lost, and the board keeps the last 200 it was sent, in memory, for as long as it runs. The alternative — a ring buffer on the server, numbered lines, the board keeping them by id — is a synchronisation protocol, and the server's own stdout is the full record for anybody debugging a build.
nameis what the catalogue calls it — Settings → MCP servers — so a row here can be joined to the entry that says where it is. A server that names nothing is still drawn, as(unnamed): *something is answering and nobody can say which* is a fact worth showing, and dropping it would be the board deciding that silence is tidier than an awkward truth.- It registers itself. It already holds
MONITOR_URL—collection/board-settings.mjsasks the board for its collections over it — so built into the deployment means no person types an address, not that the board assumes one exists. None, one and several all work with no special case, which the design requires: a collection server runs where the documents are, and the board may be on another machine. servesishttporstdio: which door is open, which is not the same question as whether the server is up. A board reaches a collection server two ways and they are independent — this socket, for watching it, and HTTP, for everything it does. A server started with no port answers over stdio, and then the socket is open, the row says answering, and nothing is listening where the catalogue says it is. That is what a Run from the board produces on a deployment with noCOLLECTION_PORT, because the runner spawns it with no arguments on purpose. A transport, never an address. Absent from an older server, and absent is notstdio.hostis a machine and never an address. An agent's registration has carried one from the start and the fleet has a Machine column for it; a server's row had nothing to put there. It is also what lets a Run reach the right runner where an installation has more than one machine.- Starting and stopping one was rejected on 2026-10-25 and the decision was overruled by the owner on 2026-10-27, with the question it could not answer. The three reasons behind the rejection were all true and none of them was the reason: it runs where the documents are, which is why Run goes to the runner on that machine rather than down this socket; the catalogue can hold several, which is why every command here names one by name; and the dependency is one-way, which it still is — the board asks, and a server with no board answers every question regardless.
What it spends — 2.9#
It is the agent's own log frame, unchanged, which is the answer to the
owner's second question: *if there is a protocol between the board and the
agents, why can the same reporting not be used between the server and the
board?* It can. /agent and /collection are two kinds on one server, the
same frames and the same ask correlation, and this is the same three lines
the agent branch runs. The protocol was never the obstacle.
What does not transfer is the bookkeeping, and that is what decides the
design. Spend and Rate are each keyed by agent, and addSpend refuses
outright — if (!agentId || !tokens) return false. That refusal is right: a
cost with nobody to attribute it to is a cost no budget can enforce, and
both the daily budget and the runaway rules are per agent. So the frame
names the agent the search was made on behalf of, and every model call a
collection server makes — a question rewritten, an answer judged — is
charged there, where its operator can already see it. Fourteen agents may
ask one server; the tokens are theirs.
agent | the asking agent's id, as that agent registered it. Required: an entry that names none is charged to nobody at all — not to the server, and not to a placeholder named after the missing field. It reaches the server as onBehalfOf on the search, which docs/reference/collection-format.md specifies |
entry | exactly an agent's: { id, at, label, model, tokens: { input, output, total } }. The id is required in practice — the ledger deduplicates on it and falls back to at|label|total without one, so two judge calls in one second whose verdicts cost the same would be one entry |
label | what the call was and for which collection — judge for unicell — and never the question. The journal rule above, for the journal's reason: it is drawn on a screen other people read |
And the capability is named: spend. Without it the board cannot tell
this server does not report what it spends from *it has spent nothing
yet*, and those are the two readings of one dash on a row — which is the
mistake CAPABILITIES exists to prevent, arriving on a different socket. A
server with no judge and no rewriter claims it too: the word is about the
mechanism, not about whether there is anything to say, and a capability that
moved when JUDGE_MODEL was turned off would read as an older build.
A server that has not claimed it is read as one that does not report,
the same way stop is read, and for the same reason: it is the reading that
makes the dash honest. Where the word is there and the figure is not, the
board says nothing has been reported yet; where the word is missing, it
says the dash is not a zero and not a report either, which is what an
older build on a current board actually is.
No run. The agent branch also files its entry into the run it belongs to
and checks the firing rules; a collection server has no runs, which
COLLECTION_CAPABILITIES has said in those words since 2.3, and an entry
filed into a run it does not belong to is a run whose cost is wrong in both
directions.
And the server's own row shows a derived total — what was spent
through it, counted again from the same arrivals so that somebody looking
at the row can ask what does this cost. It is not a bucket: nothing
budgets against it, the fleet strip does not add it, and Per min on that
row stays a dash, because the rate of this spending is already on the agents
that caused it. It is in memory and resets with the board; the agents' own
figures are durable, which is the difference between *what this board has
seen* and what a budget is compared against.
Why it is not /agent with a flag. What it may say, what the board
keeps for it and what the page draws for it are all different from an
agent's. And one thing it must never be is a row in the list the buttons
read - which is not the same as a row on the screen, and 2.8 is where that
distinction had to be made precise: the page draws a server in the fleet
table now, at the owner's request and with N/A in the columns that are not
a server's question, while onScreen() still reads board.agents alone.
Run all and Stop all act on what onScreen() returns, so a collection
server among the agents is a collection server under the panic button — and stopping it takes
every document agent's answers out at once, from a control whose armed
confirmation says stop 14 and does not say that one of the fourteen is what
the other thirteen read from. It is held in a map of its own and served under
its own key, which makes that unreachable rather than guarded against.
It is not persisted, exactly as a runner is not. The catalogue says a server exists; this socket says it is answering. Two claims, and this board already refuses to read one as the other.
How it is going — a reading (1.1 to 1.5, readings capability)#
A fourth kind of frame, and the first one that is not about an event.
register says what a server is, journal says what it did, log says what
it cost — and none of them answers is it working. A board could see that
these servers were up and nothing about whether anything was reaching
them, which is the sentence the scope opens with: *feel disconnected is a
measurement, not a feeling.*
{ "type": "reading", "reading": { "since": "2026-11-02T08:05:00.000Z", "at": "2026-11-02T08:20:00.000Z", "searches": 41, "answered": 38, "answeredUnjudged": 0, "noneAnswered": 2, "nothingNear": 1, "unjudged": 0, "embedFailures": 0, "modelMismatches": 0, "sizeOnDisk": 4194304, "embedder": "local", "building": null, "buildingSince": null } }Four of those figures are a split of searches and one is not.
answered, noneAnswered, nothingNear and unjudged are a partition —
every search is in exactly one, and they add up, which the collection
server's own suite asserts. answeredUnjudged is a breakdown of
answered: how many of the answers went back with no model having read
them against the question. Adding it to the other four counts those searches
twice, and a board that drew it beside them would invite exactly that. It
exists because the partition cannot show the fault it names — a collection
whose judge is dead reports answered climbing and unjudged flat, which is
byte for byte what a healthy guarded collection reports.
Every reading carries since, which is when counting started, and that
makes the figures legible rather than merely present: a counter resets when
the process does, and the alternative — the board accumulating across
restarts — is a second store, a synchronisation problem and a screen claiming
a history nobody kept. at is when this one was composed.
It is composed only from memory, and that is a rule rather than an
implementation detail. Nothing on the sampler's path may touch a disk, a
pool or a model: it must never be the reason a server is down, which is the
sentence board-socket.mjs opens with in both trees. So sizeOnDisk is
measured at start-up and after each build and remembered — a statSync
on a timer is a file system call every few seconds for a figure that changes
when a build ends. Every failure here is a line on stdout and a skipped
frame, never a throw.
And a reading is a secret-shaped object, so what is in one is named and
nothing is passed through. No statement and no row from the manager — a
statement is somebody's question in another costume and a row is somebody's
data. No question and no passage from the collection server. No address: the
embedder is reported as one word - local, remote or off - and never
as a URL, so the panel can say whether the model that makes the vectors runs
in this process or in another one without being told where, and the only figure here that looks like an address — the
bind — is the manager's own host:port, which is the fact being measured.
The read-only gate's refusals travel as the first keyword and nothing
else, which is why refusedBy is [{ keyword: "DELETE", times: 2 }] and
OTHER is everything this manager has no word for.
The word is readings, and it is named for the usual reason. Without it
a board cannot tell this build cannot report from *it reports and nothing
has happened*, and those are the two readings of one dash — the mistake
CAPABILITIES exists to prevent, arriving on a third socket. The word is
spelled once, in LGFlow's src/reading.js, and imported by both servers:
a word spelled at both ends is a word that can be spelled two ways, and the
failure would be a sentence on a screen rather than a blank anybody would
investigate. test/reading-seam.test.js is the one test that crosses the
seam, and it drives the board with what a shipped server actually sends.
Three states, and the board draws three sentences: no readings in the
capability list is this build cannot say; the word with no reading yet is
it reports, and nothing has happened since it came up; and a reading whose
every figure is a nought is holding 3, nothing asked in 2 hours — a fact,
and the state a person meets most often, because the sampler sends when the
server comes up. A blank panel looks like a broken feed and a 0 is a claim.
A figure that came back null is a fourth thing again — *it could not be
read* — and is never drawn as a nought.
Two figures on the board's row are not in any frame, because no process can report them: how many times it has gone and come back, since every figure a server reports is since it came up, and the bind compared with the address the catalogue holds. The second needs both halves and only the board has them.
What the number did not do#
MONITOR_PROTOCOL did not move for this either, which is now the third
time this document has said so and the reason is the same one: it is what an
agent tells a monitor, and a platform server is not an agent. Bumping it
for a reading would be reading one of the three numbers for another, in the
document that states the rule. The capability is written down instead.
/database — what a connection manager says#
The same socket in the same shape, and the mechanism is shared rather than
copied: src/board-dial.js holds the loop, the reconnect, the handshake
that comes back as a 401 rather than a socket, and the hasOwn lookup. All of
it was paid for by the collection server and none of it is about collections.
{ "type": "register", "server": { "name": "databases", "kind": "database", "host": "ODETA-PC", "serves": "http", "bound": "127.0.0.1:7500", "boardDoor": true, "capabilities": ["connections", "journal", "readings", "stop"], "connections": [{ "name": "northwind", "engine": "postgres", "reachedAt": "2026-10-22T14:05:00.000Z", "mayWrite": false }] } }
{ "type": "journal", "level": "info", "said": "registered northwind (postgres), read-only" }
{ "type": "reading", "reading": { "since": "…", "at": "…", "connections": 3, "pooled": 1, "open": 1, "idle": 1, "waiting": 0, "mostAtOnce": 4, "askedOk": 118, "refusedReads": 3, "refusedBy": [{ "keyword": "DELETE", "times": 2 }, { "keyword": "OTHER", "times": 1 }], "statementKills": 1, "connectTimeouts": 0, "boardRefusals": 0, "lastAsked": [{ "connection": "northwind", "at": "…" }] } }kind | "database", in the registration and not inferred from the socket path, for the reason every capability is named: a board reading a kind off a door is a board that will be wrong the first time a door is reused |
connections | by name and engine only — no host, no port, no database, no user. Where a database is, is already in the board's own table for whoever may see it, and this travels to a fleet row, which more people can open. reachedAt and mayWrite are three-valued: null is never tested, which is a different fact from tested and it cannot write |
bound | which interface the HTTP door actually took. serves says http and this says where, and the two come apart in both directions: loopback under a hostname is a healthy process a board draws as down, and every interface is a process anybody can reach that it draws as fine. null is it did not say, and a board that guessed loopback would be inventing the fact the field exists to measure |
boardDoor | whether MONITOR_TOKEN is set in this process — a boolean and never the value. A Register, Test or Forget refused for a missing token answers with a sentence that deliberately will not say which side is missing it, because an endpoint that distinguishes those helps somebody guess; this is the side that knows |
No spend. This makes no model calls, and the word is left out rather
than claimed-and-empty: it does not report what it spends and *it has spent
nothing* are the two readings of one dash, which is the distinction every
named capability on this board exists to keep.
Its journal carries no SQL and no row, by the rule above and for the reason the read-only gate lives in this process at all: a statement is somebody's question and a row is somebody's data, and a board is a screen more people can see. What belongs there is that a connection was registered, that a Test was run and what it found, that a question was refused for not being a read.
DATABASE_BOARD_MAY_STOP is the manager's COLLECTION_BOARD_MAY_STOP and
is on unless a deployment says otherwise, because the owner's sentence is that
the platform arrives configured and a control needing a variable set is a
control he does not have. It filters the capability list and the asks through
one function — whatTheBoardMayAsk — so that a deployment cannot
advertise a Stop it refuses or refuse one it advertises, which is the seam this
tree keeps a note about: two halves that are each right on their own.
/dashboard — watching, and asking#
A browser sends commands and nothing else. It is never asked to decide anything: every row arrives in the shape it is drawn in.
board — the whole board, every time#
Sent on connection and after anything that changes state. It is small, and a diff nobody can see is worse than a repaint.
{ "type": "board", "here": "ODETA-PC", "since": "2026-10-01T18:00:00.000Z",
"protocol": "0.7",
"runners": [ { "host": "ODETA-PC", "pid": 8124, "since": "…" } ],
"spend": { "tokens": 13050, "agents": 2 },
"rates": { "perMinute": 306, "windowMs": 300000 },
"alerts": [ { "key": "bbed7a48-…:dependency-failed:postgresql",
"agentId": "bbed7a48-…", "agentName": "Israeli News",
"type": "dependency-failed", "about": "postgresql",
"severity": "critical", "title": "postgresql failed its health check",
"detail": "connect ECONNREFUSED 127.0.0.1:5432",
"runId": null, "triggerId": null, "check": "postgresql",
"since": "…", "at": "…", "count": 4, "acked": false } ],
"alerting": { "active": 1, "critical": 1, "warning": 0, "info": 0,
"needing": 1, "acked": 0 },
"cleared": [ … ],
"thresholds": { "durationMs": 900000, "tokens": 200000, "llmCalls": 100,
"toolCalls": 200, "tokensPerMinute": null },
"agents": [ { "id": "bbed7a48-…", "name": "Israeli News", "state": "running",
"host": "ODETA-PC", "folder": "C:/Dev/Apps/israelinews",
"port": 50610, "pid": 10364, "version": "0.21",
"protocol": "0.7",
"can": ["runs", "live-runs", "delegation", "delegation-cycle", "users", "pairing", "requires", "health"],
"since": "…", "startedAt": null, "stoppedAt": null,
"lastRun": { "ok": true, "at": "…" }, "tokens": 812,
"rate": 306,
"health": { "status": "degraded", "at": "…",
"checks": [ … ], "failing": ["postgresql"] },
"current": { "running": 1, "waiting": 0 },
"alerts": { "active": 1, "critical": 1, "needing": 1 },
"canRun": true, "runners": 1, "note": null } ] }since is when the monitor started, which a row in the waiting state is
waiting since. canRun and runners are there so a row can say why a button
is not offered rather than leaving it to a tooltip nobody hovers over.
From 0.3 the board also carries, for every agent, what it last found out about
its dependencies, how fast it is spending, how many runs it has in flight and
how many alerts are about it — each worked out by the monitor, so that two
windows cannot show different answers. alerts is the whole board's worth,
because is anything wrong is a question about every agent rather than about
the one being looked at.
log — one line, forwarded#
{ "type": "log", "id": "bbed7a48-…", "entry": { … }, "dropped": null,
"tokens": 812, "spend": { "tokens": 13050, "agents": 2 } }The running totals travel with the line. A log line is not a change of state and must not redraw the board — but the figures it moves are on screen, and a chart that waited for the next state change would sit still through exactly the activity it is drawn to show.
What a dashboard sends#
| Message | Goes to | Why there |
|---|---|---|
{ type: "run", id } | the runner on that agent's machine | the agent is off; it has no socket |
{ type: "terminate", id } | the agent, else the runner | it is there, and shuts down cleanly. The runner is the fallback for one that is running and no longer answering — the one case it cannot answer for itself |
{ type: "fire", id, trigger } | the agent | |
{ type: "journal", id, limit, kind, since } | the agent | it reads its own record; nothing is kept here |
{ type: "runs", id, limit, startedBy?, failure?, state? } | the monitor | its own aggregate of what it has seen; see Asking for runs |
{ type: "trace", id, runId } | the monitor | the same aggregate, as a tree |
{ type: "health", id } (0.3) | the agent | it owns its checks; the answer is also held on the board |
{ type: "ack", key, id } (0.3) | the monitor | somebody is on that alert. It stays on the board |
{ type: "ack", cause } (0.3) | the monitor | somebody is on the whole condition — every alert under that cause |
{ type: "run-many", ids } (0.3) | the runners | start each of those agents, by the rules one Run obeys |
{ type: "terminate-many", ids } (0.3) | the agents | stop each of those, by the rules one Terminate obeys |
Many at once (0.3)#
→ { "type": "terminate-many", "ids": ["bbed7a48-…", "10488b0a-…", …] }
← { "type": "did", "of": "terminate-many", "ok": true,
"asked": 282, "done": 279,
"failed": [ { "id": "…", "name": "Mail Agent", "error": "That agent is not connected, and no runner here started it." } ],
"more": 0 }The dashboard says which, the monitor says whether. The scope a person means is what they can see — a filter applied is the filter they meant — so the ids travel; and every one of them then goes through the same code as the single button, with the same refusals. There is no second idea here of what may be started or stopped, only the same one applied in a loop.
What could not be done is named, not swallowed: twenty of them, and a count of the rest. Failing quietly for eleven agents out of three hundred is the way a button like this becomes untrustworthy.
ids is capped at 500 per message. A ceiling rather than a policy: a board
of any size a person keys in by hand fits comfortably, and a frame carrying
fifty thousand ids is a mistake rather than a request.
Every one is answered with a did:
{ "type": "did", "of": "journal", "id": "bbed7a48-…", "ok": true,
"entries": [ … ] }
{ "type": "did", "of": "run", "id": "…", "ok": false,
"error": "No runner is connected on ODETA-PC." }did carries everything the far end said, not just whether it worked: a
journal answer is the rows, and reducing it to ok/error would throw the point
away.
Correlation#
A button that asks something must hear back about its own question:
dashboard → monitor: { type: "journal", id, limit }
monitor → agent: { type: "journal", limit, ask: "ask-7" }
agent → monitor: { type: "ok", ask: "ask-7", entries: [ … ] }
monitor → dashboard:{ type: "did", of: "journal", ok: true, entries: [ … ] }Echoing ask is the agent's job, and it is not optional. An agent that
answers without it is an agent whose every command times out — which is exactly
what happened, and passed a test suite, because the stand-in agents in the tests
echoed it by hand while the real client did not. There is now a test that drives
the real connectToMonitor.
Unanswered after 10 seconds, the monitor answers the dashboard itself:
{ ok: false, error: "It did not answer." }. Saying nothing would let the person
assume it worked.
Run correlation (monitoring 0.2)#
A journal entry says which execution it belongs to. Before monitoring 0.2 it did not:
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 merges unrelated work. Only triggered runs
had an id, and it was never passed to the journal, so even those could not be
reassembled.
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 is
startedBy, notsource. A journal entry has carried asourcesince long before runs existed, and it means something else: which project wrote the line. Every entry on a machine running these sayssqlengineorisraelinews. Reusing that key put the project's name in the Source column and, worse, overwrote the older meaning on every entry written during a run. Only the five words above are starters; anything else in that field is the othersourceand is ignored.
One id per logical run, and never a second one for the same execution. A triggered run reuses the id the trigger runtime already minted and stamps its own entries with; a chat turn mints one; a delegated agent gets its own with the parent named.
The id follows execution rather than being passed through it
(AsyncLocalStorage, LGFlow's src/runs.js). That is what makes the two hard
cases correct rather than nearly correct:
- Concurrent runs never merge. Two executions in flight have separate stores. A module-level "current run" would merge them silently, and only under load.
- Delegation nests. A child agent runs inside the parent's call stack with the parent's wrapped model and tools; entering the child's run makes those calls the child's and leaving restores the parent, so a child of a child nests again with nothing passed anywhere.
Reconstruct the tree from runId and parentRunId only. Never from
timestamps, never from proximity, never from the agent id — an agent has many
runs and a run has many entries.
Delegation is in-process#
A delegated agent is not a separate process. invokeAgent compiles and runs
the child flow inside the parent, and a deploy copies the children it may call
into src/agents/<name>/ of the same project. So a parent and its children
write to one journal, and cross-agent correlation is parentRunId within
it — there is no second agent on the board to join to, and nothing to propagate
over a wire.
If delegation ever becomes cross-process, this is the field that would travel, and the shape above is already the one it would travel in.
Older agents#
An agent that does not announce runs sends entries with no runId. They are
still perfectly good log lines and must still be shown as such — they simply
cannot take part in anything that gathers runs. Say that plainly rather than
showing an empty Run Explorer:
Detailed tracing unavailable - this agent speaks monitoring protocol 0.1.run — a run, as it changes (v0.2)#
Pushed to every dashboard whenever a run starts, finishes, or gains a step. Nothing has to ask, and nothing has to refresh:
{ "type": "run", "id": "<agent id>",
"run": { "runId": "6f2c…", "parentRunId": null, "startedBy": "webhook",
"state": "running", "startedAt": "…", "durationMs": null,
"tokens": 740, "totalTokens": 1270, "cost": null, "totalCost": null,
"llmCalls": 2, "toolCalls": 1, "failure": null, "cause": null,
"steps": [ … ], "dropped": 0 } }state is running, ok, failed or waiting. Waiting is its own state:
a run stopped at a question, a form or an approval is waiting for a person,
which is neither a success nor a failure, and counting it as either is wrong
about both.
tokens is what that run's own calls spent; totalTokens is what the whole
execution spent, delegated agents included. Both, because they answer different
questions and adding them up on the page would double-count a delegation.
triggers — what an agent has armed (v0.2)#
{ "type": "triggers",
"armed": [ { "id": "nightly", "type": "schedule", "schedule": "0 8 * * *",
"timezone": "Asia/Jerusalem", "next": "2026-10-03T05:00:00.000Z" } ] }Computed by the agent's own trigger runtime, which owns the schedule. The monitor never reads a cron expression: two readers of one expression eventually disagree, and the wrong one is the one on the screen. The same list goes to stdout for LGStudio's Play, which started the process and can read it there; a monitor did not and must not have to.
Asking for runs#
Two commands answered by the monitor, from its own aggregate — not relayed
to the agent. What feeds that aggregate is the live stream and the journal
command, which is one road and not two.
→ { "type": "runs", "id": "<agent>", "limit": 50,
"startedBy": "schedule", "failure": "tool", "state": "failed" }
← { "type": "did", "of": "runs", "ok": true, "runs": [ … ], "metrics": { … } }Every filter is optional. limit is clamped to 1–200: a browser asking for
everything must not get everything, and filtering belongs on this side of the
socket for the same reason journal filtering belongs on the agent's.
The list is root runs. A delegated agent is part of the run that delegated to it and belongs in that run's trace, not beside it — listing both showed one execution twice and counted its failure twice.
→ { "type": "trace", "id": "<agent>", "runId": "6f2c…" }
← { "type": "did", "of": "trace", "ok": true,
"tree": { "runId": "6f2c…", "steps": [ … ],
"children": [ { "runId": "…", "agent": "SQL Agent", "steps": [ … ],
"children": [ … ] } ] } }Built from parentRunId and nothing else. Sorting by time and indenting what
looks nested gives a tree that is right until two things overlap — which is
exactly when somebody is looking at it.
metrics#
{ "runs": 2, "running": 0, "waiting": 0, "finished": 2, "ok": 1, "failed": 1,
"successRate": 0.5,
"durations": { "from": 2, "average": 3875, "p50": 1840, "p95": 5910, "p99": 5910 },
"tokens": 2890, "llmCalls": 5, "toolCalls": 2, "delegations": 2,
"failures": { "tool": 1 }, "startedBy": { "webhook": 1, "schedule": 1 },
"cost": { "known": null, "from": 0, "of": 4 },
"forgotten": 0, "withoutRun": 0 }Every figure says what it is of, because none of them covers all of history:
runsis what this monitor has seen since it started. It keeps nothing across a restart, so "last 30 days" is a sentence it cannot truthfully say.successRateis offinished— a run still going has not succeeded yet, and a waiting one has not either.durations.fromis how many runs had a measured duration, which is never more thanfinishedand is sometimes fewer.cost.knownisnullwhen nothing reported one. Missing cost is not zero, andfrom/ofsay how much of the total it covers.forgottenis how many older runs were let go to stay bounded;withoutRunis how many entries could not be put in a run at all.
Percentiles are nearest rank: with eleven runs, P95 is a run that actually happened rather than a number between two of them. For how slow is slow here a real measurement is worth more than a smoother curve, and it cannot produce a P99 no run ever took.
failures#
The categories are LGFlow's, decided where the thrown thing still is:
llm · tool · timeout · agent · delegation · validation · trigger · runtime · unknowndelegation is apart from agent on purpose (0.4). A delegated desk that
threw is the child's problem and may well work next time; a flow that delegates
round a circle is wrong by construction and will take that route again the
moment a model picks it. One of those is a quantity worth watching and the
other is a finding worth stopping for, which is why one occurrence of this one
pauses an agent. LGFlow refuses the circle itself and has since v0.19 - what
0.4 added is that anybody watching finds out.
A run carries both failure — what this run reported — and cause, which
resolves a failure of agent through its children to whatever actually broke.
A flow that delegated fails with agent, which is true and useless; the thing
to go and look at is the tool the child tripped over, and that is what the
summary counts.
Dependency health (0.3)#
Liveness proves three things: the process exists, the connection exists, and the event loop can answer a ping. It proves nothing about the work.
Process ✓
WebSocket ✓
Event loop ✓
PostgreSQL ✗ <- the only line anybody needed
OpenAI ✓So there are two questions, and they are deliberately not merged:
Agent state RUNNING
Agent health DEGRADEDBoth at once is an ordinary thing to be, and the pair is the useful answer. One light for both would hide whichever of the two somebody was looking for. Health never changes a state; a state never changes health.
The agent owns its checks#
The monitor does not know how to test PostgreSQL, OpenAI, an MCP server, a mailbox or anything else. It would have to know the deployment's own settings - everything this was built not to know - and it would have to reach out to every agent, which is the one direction this architecture does not go.
Dependency → the agent's own check → health event → monitor → dashboardThe monitor receives, holds and displays. It runs nothing, and the browser never runs a check: ten open dashboards would be ten times the traffic, and the figure on screen would depend on how many people were looking at it.
Where a check comes from#
Not from 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 mean every health decision needed a flow
edit, a validation pass and a deploy. Health is never in flow.json.
Two sources, both outside the language:
- Derived, by the generated
run.js, from what LGFlow already knows about this deployment:
<provider> the model provider, really reached - GET /v1/models with
the deployment's key. No tokens are spent. A key that is
merely present proves nothing: it can be revoked.
<VARIABLE> each delivery address a trigger needs, by presence only.
Posting to a webhook to see whether it works would send
somebody a message every minute.
agent registry registry.json reads, when the flow delegates.- The deployment's own, in
health.jsbesiderun.js— a file the generator does not write and a deploy does not overwrite, which is the whole reason that is where it lives:
// health.js
export default function health({ register }) {
register("postgresql", async () => { await pool.query("select 1"); });
} 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.
HEALTH=off in an agent's .env stops the checks and changes nothing else.
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.
States#
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 the same as ok and not the same as error: not
asked is not refused. An agent with nothing worth checking reports
{ status: "unknown", checks: [] }, which is honest and tells the two apart.
Cadence, and what a check may not do#
once a minute CHECK_EVERY_MS, the agent's own clock
five seconds CHECK_WITHIN_MS, the longest one check may take
never overlapping a round still running is awaited, not joinedA check that throws takes nothing down but its own line. A check that never answers is reported as an error at five seconds - a dependency that is not answering is the thing worth knowing - and the round still completes. A health subsystem that could stop an agent would be worse than no health subsystem.
What a health message may never include#
API keys, passwords, bearer tokens, connection strings with credentials, authentication headers, secret environment values. This is the easiest place in any system to leak one, because the credential is usually why it failed: a driver puts the connection string in the message, a provider echoes the header it was sent, a webhook client prints the URL it posted to.
Sanitized twice, deliberately:
- On the agent (
LGFlow/src/health.js,safeDetail): every value in the process's environment long enough to be a secret is removed wherever it appears, whatever it is called — the one rule that catches a leak nobody predicted. Then a URL keeps its scheme and host and loses the rest, because a path can be the whole secret; then anything shaped like a key or named like a password; then it is clipped. - On the monitor (
health.js,plain): the two rules that need no knowledge of the agent, for an agent built before that function existed or deployed from a fork.
Capability, not version#
{ "protocol": "0.7",
"capabilities": ["runs", "live-runs", "delegation", "delegation-cycle", "users", "requires",
"health", "pause"] }An agent speaking 0.4 does not necessarily report health: its deployment can have turned the checks off. So the question is asked of the capability, and unavailable is not unhealthy — a board that confused the two would have people mending agents that are perfectly well:
Dependency health unavailable.
Agent monitoring protocol: 0.2A collection, rebuilt (0.14)#
An agent that has rebuilt a knowledge collection says so, and this message is the whole of DSL 0.31's reason for existing.
{ "type": "knowledge-built", "id": "<flow id>", "collection": "hrPolicies",
"passages": 4120, "model": "bge-small-en-v1.5",
"builtFrom": "the HR share", "node": "build", "at": "2026-10-22T02:00:00.000Z",
"documents": 46, "skipped": 2, "method": "fixed", "size": 350, "tookMs": 13400 }The last five are what the build said about itself, and the protocol number does not move for them. They are optional and additive: an older board ignores what it does not read, and a board on this version reads an absent field as did not say rather than as zero — which is the three-answer rule this socket applies everywhere, and the difference between *every document was read* and nobody told us. A number that moved would claim an agent has to be at it to be understood, and that is not true of a field nothing depends on.
They are kept per run, in knowledge_event (schema 40), not on the
collection. The collection row is now; these are each time, and without
them was it always 129 paragraphs and *did somebody change the model under
it* had no answer on a board whose whole purpose is that record. skipped
is the one most worth having and the one that was hardest to see: an index
built from nine of forty-six documents said so on a terminal and nowhere
else.
The board keeps which model built a collection and compares it against
the previous build, so swapping a local embedder for a hosted one is written
into the trail with both names in it rather than quietly making every answer
worse. Until 0.14 nothing could write that record except a person:
/api/knowledge/* wants agents.manage, which is also the privilege for
putting an agent on this board and editing the MCP catalogue — so an agent
that had just rebuilt an index either held an administrator's credential or
the record was kept by hand.
Over this socket it needs no new privilege. The agent is already authenticated and already makes claims about itself; this is one more of the same kind as a run or a health report.
The capability is named, knowledge-built, and never read off this
number. A board that guessed would take an older agent's silence for *this
collection was never rebuilt* — and then show a record as current that
nothing has written since somebody typed it in. That is the stale
confident answer rather than the absent one, and it is the worse of the
two.
What narrows it. This socket's token is board-wide and shared, so it
proves an agent on this board and not this agent. A registration from
0.31 carries knowledgeBuilds, the collections the flow declared with
builds: true, and a report naming anything else is refused and written
to the trail. An agent that sends no such list is did not say — every
agent older than this — and its report is accepted as it always would have
been, because silence is not a denial. An empty list is a different answer:
this flow rebuilds nothing, and a report from it is refused.
What it must not carry, and does not: a path, a source address or a
credential. builtFrom is a phrase for a person — the HR share — which is
what the collection format defines it as, and the journal is read by a board.
A build by an agent is recorded in built_by_agent, not built_by: the
latter is a person by users.id, an agent is not one, and schema 35 adds
the column rather than widening what who means. All three answers stay
apart — a person did it, an agent did it, nobody said.
Which conversations an agent is holding (0.15)#
The pointer to every conversation an agent has kept, and never a word of what was said.
{ "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 }
],
"at": "2026-10-28T09:12:02.000Z" }Why it exists. Until LGFlow 0.34 nothing kept a conversation at all: a thread's state was in memory, so restarting an agent lost every conversation it was holding, and each agent's chat page opened on an empty room. A person associated with three agents had three addresses to remember and no list of anything. A window that lists conversations needs something to list, and this is it.
What travels and what does not. thread says which conversation, who
says whose, started and at say when it began and when it last moved,
turns says how many. The questions and the answers stay on the agent's own
disk, in .lgflow/transcript.jsonl, for as long as that deployment's
TRANSCRIPT_DAYS says. This board has held no passage and no journal line
since it had a database, for the reason that applies here exactly: a copy
here would always disagree with the original, and would disagree silently —
and a second durable record of somebody's conversations is a second thing to
secure, to retain and to back up.
who is null for a flow that names nobody, and that is not *we do not
know*: a per-person window has to tell a conversation with no owner from one
whose owner it was not told, and only one of those can be left out of
somebody's list.
Every report is the whole list, and replaces what the board held. One row per turn, with the board counting, was considered and refused — not over bytes, but because the count would then be the board's own number rather than the agent's, and after a restart on either side the two would disagree with nothing to say which was right. One shape, one meaning: *this is what I hold now*. A merge would also leave this board listing a conversation the agent has since dropped for age, and how long it is kept is a promise the agent makes and this board cannot keep on its behalf.
Held in memory, deliberately. state.js is *what is true now, rebuilt on
restart*, and that is the life of this list: the agent reports it at every
start and every reconnection — beside triggers and the last health report,
which travel at the same moment and for the same reason — so a board that has
just come back is told again within a second or two of the agent noticing. The
cost is named rather than discovered: an agent that is off shows no
history, and its row says so the way every row here says when something is
not answering.
Needs no new privilege. The agent is already authenticated on this socket
and this is one more claim about itself, of the same kind as a run or a health
report. Rows are rebuilt field by field on arrival, so an agent that put a
whole turn in a field this board does not read has it dropped at the door
rather than kept in memory and handed to a screen by filledIn.
The capability is named, conversations, and never read off this number.
The wrong guess here is not a missing feature but a false statement about
somebody's data: an agent that keeps no transcript — TRANSCRIPT_DAYS=0, or
any agent built before LGFlow 0.34 — has no conversations to report, and a
board that drew that silence as this person has never spoken to it would be
stating something it had never been told. With the word, an empty list means
asked, and there are none; without it, the board has not asked, and the
row's conversations is null, which is the third answer.
How often. The agent debounces: at most one report every two seconds, however many turns happen in them. How often to speak is the deployment's business and what to say is the protocol's — a busy agent must not read its own transcript once per message, and a list ordered by recency does not notice two seconds.
A chat window on the dashboard socket (0.15)#
The same socket a board is drawn from, used by somebody who is not drawing
a board: a person who holds chat.use and nothing else, whose window lists
the agents they may talk to.
Out, from this board:
{ "type": "changed", "why": "the board moved", "at": "2026-10-28T09:12:02.000Z" }In, from the window:
{ "type": "my-agents" }and back:
{ "ok": true, "me": "ori.tal@example.com",
"agents": [{ "id": "…", "name": "Grand Cafe", "state": "running",
"answering": true, "keepsConversations": true,
"at": "2026-10-28T09:12:00.000Z",
"conversations": [{ "thread": "a7f1…", "who": "ori.tal@example.com",
"started": "…", "at": "…", "turns": 14 }] }] }A window is not a watcher. watchers is what the whole board is pushed
to, unasked — every agent, every figure, the spend — and a window must never
receive one. It is held in a set of its own and gets the knock instead:
changed carries nothing but the word, so it cannot say anything about a
fleet that is not that person's, and it is the signal to ask my-agents
again. A knock also goes the moment the socket is let in, so a window that has
just opened asks without waiting for something to happen.
Why a knock and not a per-person board. The push would have to know every open window's identity and build a list each; instead there is one board on the wire and the window asks. The effect is what the feature was asked for: tick somebody onto an agent, and the push that change already causes reaches their open window in the same second — nothing restarted, nothing reloaded.
Who gets which. agents.read makes a watcher, as before. chat.use alone
makes a window. Neither is notYours, and the socket is closed as it always
was. Nobody at all is signIn — a board with people on it does not answer an
unidentified socket, which was a P0 on 2026-10-27.
my-agents is in NEEDED as chat.use, which matters because mayI lets an
unlisted command through: a command added without that line is not
refused, it is unguarded. It is the one command on this socket a window may
send; everything else — runs, journal, run, terminate, pause — is
refused to it by the same table. And the answer is filtered per person by
mayUse, agent by agent, so the gate is not what decides which agents come
back.
And a window saying it has one open — seen, the same privilege, the same
mayUse check:
{ "type": "seen", "agent": "<flow id>", "thread": "a7f1…" }and { "ok": true, "at": "…" }, which is this board's clock and not the
window's: a browser ten minutes fast would otherwise mark a conversation read
before the turn that is about to arrive in it. POST /api/my/seen is the same
act through the other door.
It is the one thing in this feature a board cannot work out, which is why the window has to say it — and why unread has three answers:
| a mark, and the conversation has moved since | unread: true |
| a mark, and it has not | unread: false |
| no mark | unread: null — nobody has looked, as far as this board knows, which is not nothing new |
A conversation reported with no at is null too: nothing to compare is not
an answer. The mark is kept durably (conversation_seen, schema 47), unlike
the pointer, for a reason that decides it: nothing re-reports when somebody
read something, so a board holding it in memory would forget on every restart
and show every conversation as never looked at.
Taking somebody's access away, now (0.17)#
{ "type": "access", "stop": ["dana@example.com"], "ask": "ask-12" }
{ "type": "access", "stop": "everybody", "ask": "ask-13" }and back: { "type": "ok", "of": "access", "stopped": 1 }.
Granting already worked and needed nothing built. An agent has never held
a list of who may use it: it asks this board at every sign-in, and the board
answers mayUse on the socket that already knows which agent is asking —
the whole value of a per-agent list is that nobody chose what it says. Tick
somebody on and the very next sign-in obeys it.
Taking it away was the half that never arrived, and it failed the unsafe way
round. A session is minted at the agent 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.
Pushed, and the twenty minutes left where they are. The reason they are not re-asked per message still holds: *a socket round trip per line of a chat, and a chat that dies whenever the board restarts*. A push costs nothing per message and works while nobody is talking, which is the case the twenty minutes never covered at all.
Two shapes, because this board knows two different things. A name, when
somebody was taken off by name, and nobody else is disturbed. everybody,
when the rule changed — an open agent narrowed, or an agent taken off the
board — because this board does not know who is talking to that agent and must
not be told. Everyone signs in again and each is answered correctly: exact
rather than clever. Granting sends nothing: signing people out for being
given something is not a thing to do.
The capability is named, access-push, in CAPABILITIES, and this board
checks it before it asks — so an older agent costs no ten-second wait for
a refusal that was predictable. There are three outcomes and they are three
sentences, because only one is ever true: applied now; *it is not running,
so it will ask when somebody next signs in*; *this agent is older than that
message, so the change reaches it within twenty minutes*. A board that
shrugged at the last two would let somebody believe a change had taken effect
when it had not.
Talking to an agent through the board (0.16)#
Five commands this board sends an agent, and the only way into an agent from anywhere but its own machine.
{ "type": "talk", "thread": "a7f1…", "input": { "question": "…" },
"who": "ori.tal@example.com", "ask": "ask-12" }
{ "type": "talk", "thread": "a7f1…", "resume": true, "answer": "yes", "who": "…" }
{ "type": "chat-page", "inWindow": true, "who": "…" }
{ "type": "describe", "who": "…" }
{ "type": "conversations", "limit": 50, "who": "…" }
{ "type": "conversation", "thread": "a7f1…", "limit": 200, "who": "…" }The two walls, and why this goes down the socket. BIND is loopback
unless a deployment says otherwise, so an agent is reachable from nothing; and
the chat's Host check is not configurable — fromHere refuses every
request whose Host is not localhost, 127.0.0.1 or [::1], so a board
proxying to agent.internal is answered Local callers only. Both are
deliberate. The socket goes the other way: the agent dialled this board,
with a token, at an address its own .env names, and this is the board
answering on the connection it was given. Nothing about either wall is
relaxed to make the window work.
who is the board's, and the agent does not check it. It cannot: the
board proved the person and 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 this board and carried on a socket the agent chose to open to
it. An agent whose flow names people refuses a talk that carries no name, in
the same words its HTTP door uses.
talk is the same drive as /run. The pause check, the
approval-decided-elsewhere refusal, the conversation's history and the
transcript are all the ones a turn already meets — one place a turn begins, so
the chat page, LGStudio's Play, a trigger and a window meet the same answers.
It does not stream: a command has one answer, so the events the HTTP door
would have streamed come back as a list, and the board writes them out as the
NDJSON the page reads. That is this version's limit and not a design.
startedBy is manual, because it names what started this — a person,
a clock, an event, a webhook, another agent — and a person asking in a window
is asking by hand. Which door is a different fact. It is also what STARTED_BY
will accept: a sixth word would be dropped silently and leave the run with no
starter.
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 the agent has: /calls
is every prompt and every tool result, and /files/<id> is what people
uploaded. chat-page is the composed document — the same bytes GET /
returns, that flow's page and its own css included — so the board puts the
agent's real page in the window rather than drawing one that looks like it.
inWindow says the asker already has a bar, a New chat and a History of
its own; what that hides is decided by the page's own stylesheet, and a flow
that insists on its own header still has one.
How long it waits. A button waits ten seconds; a turn waits five minutes. A model thinking for forty seconds is ordinary and an agent's own page waits for ever, so a window that gave up at ten would report a working agent as broken. The five minutes bound the board's table of outstanding questions rather than expressing a belief about models: a turn still running is not cancelled, and what it writes is in the agent's transcript to be read back.
And the answer carries the asker's ask back. of and id are enough
for a button — one in flight — and a conversation has two the moment somebody
types while one is running. Without it a window would attach the first
answer to the second bubble, which looks like the agent saying something it
did not.
The capability is named, chat, in CAPABILITIES. An older agent answers
no such command: talk, which is honest and arrives too late: the window
would have offered a pane, drawn it, and failed inside it. With the word the
row says open its own page — too old for this window before anybody clicks.
Current activity (0.3)#
What is this agent doing right now. Nothing new is reported to make this possible, and that is the point: a run that has started and not ended is a run in flight, and 0.2 already says both. A second activity protocol beside the run events would be a second thing for an agent to get right and a second thing to disagree with the first.
It rides along with every run change, and is also in the answer to runs:
{ "type": "run", "id": "<agent>", "run": { … },
"activity": {
"running": 2,
"waiting": 1,
"runs": [
{ "runId": "abc123", "startedBy": "webhook", "state": "running",
"elapsedMs": 18000, "tokens": 740, "totalTokens": 3280,
"llmCalls": 8, "toolCalls": 3, "cost": null, "totalCost": null,
"where": ["SQL Agent"],
"lastStep": { "name": "execute-query", "kind": "tool", "ms": 55, "at": "…" },
"interrupted": null }
] } }An agent can have more than one current run. Two webhooks arriving together, or a schedule firing while somebody is chatting to it, is the ordinary case - so this is a list and the operator picks one. A panel built around the current run shows the wrong one at exactly the moment it matters.
Root runs only. A delegated agent is part of the run that delegated to it:
it appears as where — how far into the delegation chain execution has got —
and never as a second thing happening.
**lastStep is the last step that finished.** A model call or a tool call is
journalled when it returns, so nothing can honestly claim to show the call in
flight, and the page says so in those words rather than implying otherwise.
Waiting is not running work. A run stopped at a question, a form or an approval is waiting for a person: it is not failed, and it is not consuming anything. It is counted separately, it says what it is waiting for, and it is never called stuck or runaway however long it has been there.
A delegated run now reports both ends (0.3)#
{ "type": "run", "phase": "enter", "runId": "<child>", "parentRunId": "<parent>",
"agent": "SQL Agent", "at": "…" }
{ "type": "run", "phase": "exit", "runId": "<child>", "parentRunId": "<parent>",
"agent": "SQL Agent", "ok": true, "outcome": "done", "durationMs": 120 }Before 0.3 the only thing that said a delegation had happened was the runId
on the entries the child wrote. A monitor opened the child's run on its first
entry and never heard it end, so every delegation sat on the board as running
for ever — and anything counting what was in flight counted it. The chat path
reports these through serveAgent; a triggered run reports them through the
tracer the generated run.js passes in.
startedBy is deliberately absent from these: what started a delegated run is
whatever started the whole execution, which inRun already hands down.
An agent that goes away mid-run#
run running
socket closed
outcome none - and none is inventedThe run is marked interrupted with the time, and its state is left exactly as
LGFlow last reported it. Not failed, and certainly not ok: execution truth
belongs to LGFlow, and a monitor that guessed an ending would be making one
up. The board says *the agent stopped with this run in flight, so it has no
outcome*, and the agent's journal still has whatever was written.
Resources (0.3)#
tokens by entry id, deduplicated, since the monitor started
rate tokens a minute, trailing, over five minutes
llmCalls per run and rolled up through delegation
toolCalls the same
cost only what LGFlow reported. Missing is never zeroResource aggregation is the monitor's. The browser displays; it does not add up token totals, work out a rate, count active runs, or decide whether something is a runaway - which is what keeps two windows on one monitor from disagreeing.
One concept, one name. tokens is what a run's own calls spent and
totalTokens is what the whole execution spent, delegated agents included;
there is no third name for either.
The rate#
A trailing average over five minutes, in memory, reset by a restart - which is correct, because the samples are measurements this monitor took and it took none before it started. Deliberately not instantaneous: a fan-out legitimately spends a burst in two seconds, and an instantaneous rate would read a quarter of a million a minute and alarm somebody about normal work.
It is fed from what Spend counted as new, so reading a journal - which
returns entries already counted - does not register as spending happening now.
Runaway detection (0.3)#
A loop that keeps asking the model, a fan-out that fans out again, a tool whose result the flow retries for ever: none of these fail. They run, and keep running, and the first anybody knows is the bill or the rate limit.
Explicit thresholds, chosen numbers - not a model of normal. Statistical anomaly detection needs history this monitor does not keep, and answers a question nobody asked: an operator does not want to be told a run is unusual, they want to be told it has spent 81,000 tokens.
| rule | default | why that number |
|---|---|---|
durationMs | 900000 (15 minutes) | long enough that no ordinary run reaches it, short enough that somebody still cares |
tokens | 200000 | roughly a long book in one execution; a legitimate fan-out over a hundred documents can approach it, so it is not lower |
llmCalls | 100 | above this it is almost always a loop |
toolCalls | 200 | the same, with headroom for a flow that reads a hundred rows |
tokensPerMinute | off unless configured | depends entirely on what the agent does; a fan-out's first thirty seconds look exactly like a runaway |
Configured in agents.json, beside the agents - one file, already there,
already hand-editable, and re-read without a restart. Never in flow.json: a
monitoring threshold must not require editing, validating and deploying what an
agent does.
{ "agents": [ … ],
"runaway": { "durationMs": 120000, "tokens": 50000, "llmCalls": 30,
"toolCalls": 50, "tokensPerMinute": 20000 } }null switches a rule off, which is the honest way to disable one - a threshold
of zero would fire on everything. Anything unreadable is refused and said out
loud rather than rounded: treating "50k" as zero would fire on every run
there has ever been.
An agent's own entry may carry the same block, read over the fleet's, so one
that says something about tokens and nothing about llmCalls keeps the
board's answer for the second. A policy that is switched on still has the last
word on tokens and tokensPerMinute.
Each agent's row carries what is in force and who said each number:
"runaway": {
"thresholds": { "durationMs": 900000, "tokens": 500, "llmCalls": 100,
"toolCalls": 200, "tokensPerMinute": null },
"from": { "durationMs": "default", "tokens": "agent", "llmCalls": "default",
"toolCalls": "default", "tokensPerMinute": "default" },
"policy": "High Usage Agents"
}from is per rule, not one word for the set, because that is how they arrive: a
policy carries a token ceiling and says nothing about tool calls, so one number
is the policy's and the next is still the file's. policy is there only when
one spoke. The provenance is kept rather than worked out by comparing
figures - an agent that sets tokens to exactly the fleet's number has still
said so, and a board that guessed would tell that deployment its file was being
ignored.
Rules fire at the threshold as well as above it. A rule is assessed only
while a run is live, because a run that has finished cannot be inspected,
stopped or helped - which is also why a completed run keeps no live warning. A
waiting run is exempt from durationMs, and a run whose agent has gone is
exempt from everything: it has an alert of its own.
A warning, never an outcome#
Run state RUNNING <- what LGFlow says happened
Control state WARNING <- what this monitor thinks about it
Reason 52,418 tokens — the threshold is 50,000Nothing here rewrites running as failed.
Execution truth belongs to LGFlow; operational assessment belongs to the
monitor, and the two are shown side by side. The warning names the rule that fired and by how much — *abnormal
behaviour* is not an alert, it is a feeling.
Alerts (0.3)#
A current condition, not a log of things that have gone wrong. The difference decides everything: a condition that clears resolves its alert, a condition reported fifty times is one alert, and nothing is written down anywhere.
agent-stopped critical it is doing nothing; somebody must start it
dependency-failed critical something it needs is refusing; it cannot work
agent-not-answering warning a pong was missed; usually a network coming back
runaway warning a threshold was crossed. The run has not failed
run-failed warning a run failed, with what kind of failure it was
trigger-failed warning a trigger's run failed, pointing at that runSeverity is about what somebody has to do: critical is "it cannot do its job", warning is "it is working and something is wrong". Three levels, because with ten nobody could say what the difference between four and five was.
A failing dependency raises one alert per dependency rather than one per degraded agent: this agent is degraded is never the useful sentence, PostgreSQL is refusing connections is - and raising both would be two alerts for one condition.
Identity#
The failure mode of every alerting system is the storm. So an alert's identity
is agentId + alertType + what it is about, and reporting the same condition
again updates that one alert — deterministic, and one alert however many
times it is reported:
SQL_ENGINE:dependency-failed:postgresql
SQL_ENGINE:runaway:abc123:tokens
SQL_ENGINE:runaway:rate:tokensPerMinute
SQL_ENGINE:agent-stoppedTwo rules follow, and both matter. The same runaway rule on two different runs is two alerts, because they are two things to look at. The same dependency failing for an hour is one alert, because it is one thing to fix — with a count and a first-seen time, which is what the repetition is actually worth.
Causes: the same alerts, grouped by what they are about (0.3)#
An identity per agent is right — forty agents that cannot reach PostgreSQL are forty things in forty different states. But they are one thing to fix, and past a handful of agents that is what a board has to say. So alerts also carry a cause, which is the part of their identity that is neither the agent nor the run:
dependency-failed:postgresql every agent whose postgresql check fails
runaway:tokens every run over the token threshold
agent-stopped every agent that has stoppedThe board carries them grouped, worst first and then by how many agents — which puts one database, forty agents above one agent, one failed run:
{ "causes": [
{ "key": "dependency-failed:postgresql", "type": "dependency-failed",
"about": "postgresql", "severity": "critical",
"title": "postgresql failed its health check",
"detail": "connect ECONNREFUSED 10.0.0.4:5432",
"since": "…", "at": "…", "count": 37, "acked": false,
"agents": [ { "key": "<alert key>", "agentId": "…", "agentName": "Sales Digest 31",
"check": "postgresql", "runId": null, "triggerId": null, "acked": false } ] } ] }Grouped by the monitor, like every other figure, so two windows cannot decide differently what counts as the same condition. One agent keeps its own sentence — "A run failed: tool" says more than "Runs failing" — and past one it becomes the condition's.
{ type: "ack", cause } acknowledges the whole of it, because the unit a
person acts on at this size is the condition and not the forty alerts beneath
it.
Lifecycle#
active → resolved the condition stopped holding
active → acknowledged somebody is on it; the alert stays, because it is still trueConditions are reconciled from the facts, never resolved one at a time from the other direction: health arrives as a list of checks, and whatever is not failing in it has recovered - or is no longer checked at all. Walking the alerts from the facts is the only direction that cannot drift into a board with an alert for a database that came back yesterday.
A resolved alert is remembered for a few minutes and shown as cleared, because did somebody fix it or did it come back by itself is the next question after an alert vanishes. That is not a history: it is bounded, and it is forgotten.
Acknowledging does not hide anything - hiding a true thing would be a board that lies. It leaves the count of alerts nobody has picked up. A condition that clears and returns is news again, and arrives unacknowledged.
Where an alert leads#
Clicking one selects the agent and the thing that raised it: a failing dependency opens Health, a runaway opens that run's trace, a failed trigger opens Triggers. Nobody should have to navigate back to the cause of an alert.
Beside each one are the controls that already existed — run, terminate,
fire, health — and nothing else. No automatic remediation: nothing is
terminated, restarted, retried or disabled by this monitor. It detects and
presents; a person decides.
Monitor restart (0.3)#
Said plainly, because implying otherwise would be the worst thing this document could do:
alerts gone, and recreated from conditions that still hold
token totals and rate reset - they were measurements this monitor took
runs and metrics gone; reconstructed from live events and journal reads
health reconstructed as agents report, which is within a minute
- and at once for any agent that reconnects
current runs reconstructed where the agents are still running
liveness reconstructed through reconnection, within 35s
journal history untouched. It lives on the agents and always did**What is gone is everything about now.** There is a database (SQLite beside
agents.json, since 0.4) and it holds exactly what a policy needs to be kept -
policies, prices, spending by window, how each run turned out, and the engine's
own state. It is not a second execution record and must never become one: the
durable execution record is each agent's own
<agent>/.lgflow/journal.jsonl, and a monitor that quietly became a second
source of that truth would disagree with the first one by the end of the week.
Whether a run answered anything (0.18)#
On a run's exit, and only where it finished:
{ "type": "run", "phase": "exit", "runId": "…", "outcome": "done", "ok": true,
"answered": false }and in a journal turn entry read back, beside its outcome:
{ "kind": "turn", "outcome": "done", "answered": false,
"withheld": ["input", "outputs", "steps"] }What it is for. A graph that reaches END with every output channel
empty is a successful run by every measure this board had. ok is
outcome === "done", so the drawer read SUCCESS RATE 100.0% · 2 of 2
finished over a run that left the person in front of the agent with a blank
screen. An operator watching a board saw a healthy agent.
Why a boolean and not the outputs. The agent already knew: a turn's
journal entry carries outputs. It never leaves the machine — the agent
sweeps content out of everything it sends and names what it withheld, so a
full answer and an empty one both arrive here as withheld: ["outputs"].
There was no field in which the two differed. A boolean is the one shape that
crosses that line: it says whether anything was written and names nothing that
was.
Three answers, and the third is the point. true wrote a channel,
false reached the end having written none, and absent is *nobody has
said* — every agent older than 0.18, and every run still going. A board
reading absent as false would put told nobody anything on a working
fleet's screen, so the column that keeps this is nullable and read three ways
at both ends of ledger.js. Announced as the answered capability for
the same reason, never read off the protocol number.
Not on a run that is waiting or failed. A turn waiting for a person has
written nothing yet, and false about it would be the same mistake one
layer out. A failure's outcome is the fact about it.
What the board does with it. successRate is unchanged: a run that
answered nothing did finish and did not fail, and folding a second meaning
into the one figure an operator trusts is worse than the gap it closes.
metrics gains answeredNothing and answeredSomething beside it, which
do not add up to ok — the difference is the runs nobody said about —
and the drawer draws a row only when there is something to say.
What an MCP server offers (0.19)#
From an agent, on its own socket, once per server it is wired to:
{ "type": "mcp-tools-offered", "id": "…", "server": "crm",
"offered": ["findCustomer", "create_ticket"], "missing": ["listOrders"] }Why the agent and not the board. This is the whole of the design and it
is a constraint rather than a preference. The board holds the name of the
environment variable carrying a server's token and never the token, so it
cannot authenticate to an HTTP server; and reaching a stdio server means
starting it, by running a command out of this board's own catalogue — which
would turn agents.manage, may manage the fleet, into *may run code on the
monitor*. An agent has the credential by construction, connects to the server
anyway to do its job, and already asks it for its tools on its health
interval. So the only party entitled to ask is the one already asking, and
this frame is it reporting what it heard.
offered is the server's own names; missing is the disagreement. The
generated service's listTools() already computes both: every tool name the
server gave, and every tool the flow declared that the server no longer
has. The second is the single most useful fact here — *a tool an agent
declares that the server no longer offers* is every call failing from the
next one onwards, and nobody here is told when a third party's catalogue
changes. The board stores offered and derives missing against what the
agent declared, because storing a derived figure is how two numbers come to
disagree.
**And what the agent declared is the registration's mcp field, on the
live row — not agent_mcp / agent_mcp_tool, which are the
subscription: what somebody on this board decided an agent may call,
written by one route and nothing else. The two are a different row of the
four-way table in LGFlow's CLAUDE.md and they keep differently, which is
worth knowing before reading a screen: the declaration lives in a Map and
is gone when this board restarts, returning only as agents reconnect, while
what a server offered is on disk and survives. A freshly restarted board
with nothing connected therefore knows what each server has and nothing
about what anybody asked of it** — the exact opposite of the gap this frame
was added to close, and both halves have to be reported separately rather
than merged into one list.
Announced as the mcp-tools-offered capability, never read off this
number. An agent that says nothing about its server's tools has not said
the server has none — the same three-way reading as answered above, and the
reason CAPABILITIES exists at all.
No credential and no address travels. Tool names and the server's own
descriptions, which are already in every generated project, and nothing from
.env. The board does not learn where the server is from this frame; where
it is comes from the catalogue, which is a different screen and a different
privilege.
What is kept, and why it is two tables. mcp_server_tool holds a row per
tool keyed by server, not by agent — two agents on one server must not
produce two answers about what it has, so the later report wins and the row
says whose it was. A tool the server stops offering is marked gone, not
deleted, because a disappearance is the row an operator has to act on and a
deleted row is indistinguishable from a tool nobody ever heard of.
mcp_server_heard holds only that a server was asked, by whom and when.
That is the fact the tool rows cannot carry: a server nobody has asked and a
server that answered with an empty list both have no tool rows, and they are
different answers. Keeping the ask separately is the same shape, and the
same reason, as agent_mcp beside agent_mcp_tool.
A report about a server this board has never registered is kept.
agent_mcp.server references the catalogue because an attachment is this
board deciding something, which is meaningless for a server it does not know.
A report is the opposite: an agent reaching a server nobody registered here
is an ordinary deployment, and a foreign key would throw away the only report
anybody was ever going to get about it.
Liveness#
ping every 20s · not answering at 30s · socket dropped after two misses
nothing is called down for the first 35s after the monitor startsWebSocket ping frames, not messages of our own: ws answers one unasked, so
there is nothing to parse and nothing in the message stream to log. Both ends
send them, for different reasons.
- The monitor pings to drop dead sockets promptly.
- The agent pings because a connection can be half-open — a laptop sleeps, a NAT entry expires, a VPN drops — and to the side still holding it the socket looks fine: no close, no error, and so no reconnection, ever. A pong is how it finds out.
A pong says more than that the socket is open: a Node process whose event loop is wedged cannot answer a ping frame either. It does not say the agent is working — a missing key or a database that is down looks entirely green, which is what the agent's own startup check and its run events are for.
Reconnection is the agent's, backing off 1s, 2s, 4s… to one attempt every
30 seconds, for ever. So a monitor started after its agents is found by them
within half a minute, with nothing restarted and nothing pressed. SETTLING_MS
(35s) must stay longer than that backoff — a test reads the backoff out of
LGFlow rather than trusting this paragraph.
States#
Eight, because a red light that means six things cannot answer the only question anyone asks a board: is this a problem?
| State | What it means | What moves out of it |
|---|---|---|
running | a socket is open and answering | the socket closes, or a pong is missed |
stale | open, and a pong has been missed | a pong arrives, or two misses drop the socket |
stopped | the socket closed, and when | it registers again |
never | keyed in, never once reported | it registers |
silent | a runner started it in that folder and nothing has been heard from it since | it registers — or it never does, and the answer is a missing MONITOR_URL |
unregistered | connected, and nobody keyed it in | key it in |
unidentified | connected, and its flow.json has no id | save the flow, deploy again |
waiting | this monitor has only just started, so it cannot say | the agent reports, or 35s pass |
Three rules worth stating because all three were once wrong:
- An agent the monitor watched stop says
stopped, however new the monitor is. That is not a guess;waitingis only for an agent nothing has been heard from at all. - And however it was started. A folder a runner has ever started is
remembered for the life of the monitor, and
silentwas once decided by that launch alone — so an agent started from the board, run, and then stopped went on being called started, not reporting for ever, and everything that keys onstopped, the critical alert included, stopped firing for it. A launch counts only while it is newer than anything heard from that agent. - The window closing is pushed, not polled. With no agent connected there is not even a ping to ride on, so the monitor sets one timer for the one moment it changes its mind by itself. Without it the first board a dashboard was sent said waiting to hear for as long as it stayed open.
Tokens#
Counted by the monitor, by entry id, from everything it has seen — journals that were read, and entries that arrived live.
- By id, so an entry seen twice is counted once. A figure that grows when you look at it is one nobody believes.
- In the monitor, not the page, because a page held the tally and lost it on every refresh, and two pages open on the same monitor disagreed.
- It is not an account. It covers what this monitor has seen since it
started, and an agent's journal is itself compacted at two thousand entries.
spend.agentssays how many agents it covers so the card can say *from 2 of 3 agents* rather than presenting a partial figure as though it were a bill.
These figures do not survive the monitor restarting: they are about now, and
now is not something to keep. What is kept is what a policy needs to be kept -
spending by window, how each run turned out, and the engine's own state - in
SQLite beside agents.json (0.4). A total across agents is still worked out
from what is in memory, which is why it says how many agents it covers.
What does not travel#
A journal holds what the agent actually handled: the schema it was given, the
rows it read, the mail it answered. None of that leaves the machine.
withoutPayloads() removes request, reply, args, result, input,
outputs, steps and show, and names what it removed:
{ "id": 41, "kind": "run", "ok": true, "durationMs": 1840,
"withheld": ["input", "outputs"] }Named rather than silently absent, so a reader can tell there was nothing from there was something and you are not being shown it.
MONITOR_DETAIL=full in an agent's .env sends the rest, deliberately. It is
per agent, set where the agent runs — a monitor cannot ask for more than it is
being given.
A password travels once, in one direction, and is never stored at either end
(0.5). It goes up in a verify and is gone: the agent holds it for the length
of one message, the monitor checks it against a verifier and drops it, and
neither writes it to a journal, a log or an alert. What the board keeps is
scrypt$N$r$p$salt$hash — its own parameters beside it, so a verifier made
today still checks when the cost settings move.
Who may connect#
Loopback by default, and the server refuses to start on any other host
without a token: Terminate with nobody checking who is asking is a kill switch
for anyone who can reach the port.
node index.js --host 0.0.0.0 --token <secret>Agents and runners send it as the x-monitor-token header. A dashboard does
not: it is a person already at the machine. This is the one piece to settle
properly before a monitor runs anywhere real.
HTTP#
The board's own list is ordinary REST — it is editing a file, not watching anything.
GET / the page
GET /api/agents the keyed-in list
POST /api/agents key one in { id, name, folder, host?, note? }
DELETE /api/agents/<id> forget oneid must be a flow id — 8-4-4-4-12 hex — and a name is required, because the
name is what the board is read by.
GET /api/users everybody keyed in, with whether they can sign in
POST /api/users key one in { name, email, role?, password? }
PUT /api/users/<id> change name, email or role
PUT /api/users/<id>/password set or replace one
DELETE /api/users/<id> forget oneGET /api/users needs admin, unlike every other read on this board. A list
of everybody who works here — names, addresses, roles, who can sign in — is a
staff directory, not a thing to hand to anybody who can see the board. It is its
own line in the gate rather than a cleverer rule, because the exception is the
point.
No route returns a password in any shape. What is kept can confirm a guess and cannot produce the answer, so there is nothing to read back and no field that offers to.
Where these live#
LGFlow/src/monitor.js the agent end: connectToMonitor
LGFlow/src/generate/scaffold.js what run.js does with it: tellMonitor, emitEntry
LGFlow/src/journal.js withoutPayloads
LGStudioMonitor/server.js the three sockets, ask correlation, forwarding
LGStudioMonitor/state.js Board, Spend, and the four numbers that only
mean anything together
LGStudioMonitor/runner/runner.js one per machine