Symphonix Documentation · Core 0.61.0

The monitoring protocol

What an agent tells the board, and what the board may tell an agent.

This is the engineering reference, rendered from the repository

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 deploy

Three 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:

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.

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 situationwhat governs
no board configuredthe flow alone — a developer with no monitor must still be able to work
board configured, has spokenthe subscription
board configured, never spokennothing. 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 backwhat 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 allwaits

Which is the three-way rule again, and its rows do not say what the MCP ones say:

the agent's situationwhat governs
no monitor configuredits own card — a developer with no monitor must still be able to answer their own agent
monitor configured, has spokenthe rule the monitor named
monitor configured, silentnobody 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#

MessageWhat 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#

MessageWhat 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#

MessageWhat 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 with in. The trust on this socket runs backwards from everywhere else here — the server proves who it is with MONITOR_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.

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.

agentthe 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
entryexactly 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
labelwhat 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
connectionsby 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
boundwhich 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
boardDoorwhether 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#

MessageGoes toWhy there
{ type: "run", id }the runner on that agent's machinethe agent is off; it has no socket
{ type: "terminate", id }the agent, else the runnerit 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 agentit reads its own record; nothing is kept here
{ type: "runs", id, limit, startedBy?, failure?, state? }the monitorits own aggregate of what it has seen; see Asking for runs
{ type: "trace", id, runId }the monitorthe same aggregate, as a tree
{ type: "health", id } (0.3)the agentit owns its checks; the answer is also held on the board
{ type: "ack", key, id } (0.3)the monitorsomebody is on that alert. It stays on the board
{ type: "ack", cause } (0.3)the monitorsomebody is on the whole condition — every alert under that cause
{ type: "run-many", ids } (0.3)the runnersstart each of those agents, by the rules one Run obeys
{ type: "terminate-many", ids } (0.3)the agentsstop 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, not source. A journal entry has carried a source since long before runs existed, and it means something else: which project wrote the line. Every entry on a machine running these says sqlengine or israelinews. 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 other source and 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:

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:

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 · unknown

delegation 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    DEGRADED

Both 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 → dashboard

The 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:

  1. 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.
  1. The deployment's own, in health.js beside run.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 · unknown

Any 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 joined

A 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:

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.2

A 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 sinceunread: true
a mark, and it has notunread: false
no markunread: 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 invented

The 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 zero

Resource 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.

ruledefaultwhy that number
durationMs900000 (15 minutes)long enough that no ordinary run reaches it, short enough that somebody still cares
tokens200000roughly a long book in one execution; a legitimate fan-out over a hundred documents can approach it, so it is not lower
llmCalls100above this it is almost always a loop
toolCalls200the same, with headroom for a flow that reads a hundred rows
tokensPerMinuteoff unless configureddepends 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,000

Nothing 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 run

Severity 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-stopped

Two 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 stopped

The 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 true

Conditions 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 starts

WebSocket 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.

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?

StateWhat it meansWhat moves out of it
runninga socket is open and answeringthe socket closes, or a pong is missed
staleopen, and a pong has been misseda pong arrives, or two misses drop the socket
stoppedthe socket closed, and whenit registers again
neverkeyed in, never once reportedit registers
silenta runner started it in that folder and nothing has been heard from it sinceit registers — or it never does, and the answer is a missing MONITOR_URL
unregisteredconnected, and nobody keyed it inkey it in
unidentifiedconnected, and its flow.json has no idsave the flow, deploy again
waitingthis monitor has only just started, so it cannot saythe agent reports, or 35s pass

Three rules worth stating because all three were once wrong:


Tokens#

Counted by the monitor, by entry id, from everything it has seen — journals that were read, and entries that arrived live.

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 one

id 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 one

GET /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