Deployment
What the Studio writes, what it vendors, and how the folder becomes an image.
It is the same file the platform's own tests are written against — LGFlow/docs/guides/deployment.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.
From descriptions to a project that runs on its own.
A flow that is ready to generate can have its code written for it:
npm run check -- examples/approval-desk/flow.json
npm run generate -- examples/approval-desk/flow.json --out ../refund-desk
cd ../refund-desk && npm install && npm test
node run.js '{"request": "refund ALFKI 240 for a damaged crate"}'The result is a standalone project:
refund-desk/
flow.json the flow it was generated from
run.js node run.js '{...input}' - asks for approvals at the terminal
src/nodes/<handler>.js one file per handler - written by the model
src/routers/<name>.js one file per router - written by the model
src/tools/<name>.js one file per tool - written by the model
src/services/<name>.js each service's default - written by the model
src/registry.js createHandlers(), one constant per setting, bound exports
src/agent.js createAgent(options) - the compiled graph
test/agent.test.js smoke tests
src/llm.js the model behind llm nodes - OpenAI or Claude
src/files.js where file channels keep their bytes, when it has any
.env.example every setting it needs, empty
.gitignore · README.md · .vscode/launch.json
Dockerfile · .dockerignore the same folder as one image, when you want it
deploy/nginx.conf.example a proxy, for when more than one person uses it
vendor/lgflow-0.17.0.tgz LGFlow itself, pinned
.lgflow/manifest.json what was generated, from what
.lgflow/project.json what this project is called, and who has worked in itWhat .lgflow/ may and may not be committed#
The folder is not ignored wholesale, because two files in it are worth
keeping: manifest.json says what was generated and from what, and
project.json says what the project is called — which is the whole point of
the name living in the folder rather than in a list beside one studio.
Everything else in there is this machine's, and the generated .gitignore
names each one:
.lgflow/journal.jsonl what this agent has done
.lgflow/transcript.jsonl what was said — one line per turn
.lgflow/waiting.db runs stopped for a person — conversations
.lgflow/files/ what people uploaded into `file` channels
.lgflow/running.json the lock: a pid on this host
.lgflow/previous/ versions a Fix set asidefiles/ is the one to understand. A flow with a file channel saves what
people send it there — an invoice, a bank statement, whatever the customer
attached. It was not on this list until 0.29.2, which means an agent deployed
before then has a .gitignore that does not cover it: redeploy it, or add
the line by hand, before that folder becomes a repository. A history is the
one place bytes never come back from.
The conversations, and how long they are kept#
transcript.jsonl is new, and it is the strongest case on that list: it is
nothing but what people said, one readable line per turn, kept on purpose.
Until this version nothing kept a conversation at all — a thread's state was
in memory, so restarting an agent lost every conversation it was holding,
and the chat page opened on nothing.
It stays on the agent's own machine. The board keeps a pointer — which conversation, whose, when it last moved, how many turns — and never the text, for the same reason it holds no passage and no journal line: a second durable copy of somebody's conversations is a second thing to secure, to retain and to back up, and it would disagree with the original silently.
Two things it never holds. Every value in this deployment's environment that is long enough to be a secret is taken out of every turn, wherever it appears and whatever it is called, and so is every webhook path this agent serves — a path can be the secret itself. Exact strings only: nothing is matched by shape, because a conversation is content and a rule that eats a pasted link or a long order number is worse than the leak it was guessing at. What it cannot do is tell a person typing their own password from a person typing a sentence, and that is said here rather than left to be inferred from the word redacted.
And how long. One setting, commented out in .env.example:
TRANSCRIPT_DAYS=30| it says | what happens |
|---|---|
| nothing (the default) | kept until the file's own line limit — 2,000 turns |
| a number of days | a turn older than that is dropped, and is gone from the file |
0 | nothing is kept at all |
| anything else | the agent does not start, and says which variable |
The default is nobody said on purpose: a window chosen here would be this
engine deciding how long somebody's words are kept at a deployment it knows
nothing about — and this feature exists because conversations vanished at a
restart, so a default that re-introduced the vanishing on day 31 would be the
same surprise in slower motion. The deployment that owes somebody a
retention promise is the one that can make it. The last row is the opposite
rule from BIND, which falls back to loopback: a mistyped bind fails where
somebody can see it, and a mistyped retention window fails silently and in
the safe-looking direction.
Pruning happens when the agent is next restarted and on the first turn after the oldest line goes stale. There is deliberately no timer — a timer in a library is a handle that holds a process open — so an agent that takes no further turn and is never restarted keeps what is on its disk. In a container, mount the volume: without it the conversations go with the container, which is the thing a person notices before they notice the journal.
The one file a deploy will not write twice#
.env is the person's, not the generator's: it holds their keys. It is created
when it is missing and never written again - not by a redeploy, not by
force, not by anything. That is what makes it safe to type a key into.
The cost of that rule was that every first deploy needed somebody to remember
to open the file, and an agent whose key was missing looked - from a board -
exactly like an agent that was broken. So generateProject takes settings,
names to values, and uses them for the file it was going to create anyway: a
name given a value is written and uncommented, because a value on a
commented line is a value nothing reads; a name given an empty value keeps its
line exactly as it was, since empty means *this deployment needs it and the
machine supplies it*. LGStudio fills this in from its own Settings screen, and
its Deploy dialog has a tick for whether to use them at all.
Reaching an agent from somewhere else (v0.21)#
An agent binds 127.0.0.1 and refuses any Host but this machine's. That is
the whole of its security model, and it is deliberate: an agent listening to a
network and believing a header about who is asking would let anybody on
that network be anybody.
This paragraph said "and neither is configurable" until 0.26.2, when the bind became configurable and the
Hostcheck did not. The reason is further down, under the same folder, as an image - inside a container127.0.0.1is the container's own loopback, so an agent bound to it answers nobody at all, and the boundary is drawn by the published port instead. TheHostcheck is unchanged and is not configurable.
So the way more than one person reaches one agent is something in front of it, on the same machine, that has already decided who they are:
user → proxy (:443, your SSO) → 127.0.0.1:PORT → the agentThe agent is unchanged by this. It still binds loopback, still refuses a
strange Host - the proxy sets Host: localhost - and still has no idea how
to authenticate anybody. What it gains is one thing: it reads
X-Forwarded-User (and X-Forwarded-Email, X-Forwarded-Preferred-Username,
Remote-User) and puts it on every run it reports, as who. That header is
believed only because nothing but the proxy can reach the agent at all.
Three things have to be true for this to work, and a deploy writes all three down:
| A port that does not move | PORT= in .env. Without it the agent takes a free port on every start, and an address handed to sixty people points at nothing after the first restart. The monitor's runner no longer names a port, and strips PORT from what it hands on - a deployment's port is the deployment's business |
| A proxy that forwards properly | deploy/nginx.conf.example, generated beside the project with this deployment's name in it. Three lines in it are easy to leave out and each fails silently: the Host rewrite, the two websocket lines, and the user header |
| The name the proxy publishes (0.26.2) | PUBLIC_ORIGIN=https://<your server_name> in .env. The chat is a socket, and the agent refuses a handshake whose Origin is not one it knows. A proxy rewrites Host and cannot rewrite Origin: a browser sends the address in its own bar and cannot be made not to. Without this the page loads through the proxy and the chat never connects |
And one for an agent that stops for people (0.26.6): .lgflow/ has to be a
place that survives the process, because from this version a form half-filled
and an approval not yet answered are kept in .lgflow/waiting.db. On a host
that is already the folder's. In a container it is the volume the generated
Dockerfile documents - -v <name>-state:/app/.lgflow, which existed for the
journal and now carries somebody's half-finished work as well. The agent says
on every start which kind of wait it got; Waiting: in memory is the line
that means a redeploy will throw a person's form away.
The third one was missing until 0.26.2, and the first two were not enough.
Every proxied deployment this platform described had a chat that connected and
then waited for ever - the failure the two websocket lines exist to prevent,
arriving from a header nobody had looked at. It took a phone on the network to
find, because curl through a proxy answers 200: a program sends no Origin
at all, which fromHere has always let past deliberately.
Do not set Origin in the proxy instead. It is the obvious one-line fix,
it works, and it makes every origin acceptable - so any page on the web that
can reach the proxy could open the agent's socket, carrying the person's
cookies, which is exactly what an auth_request in front would wave through.
Naming the one origin keeps the check doing its job. An agent that cannot read
the value says so on stdout and goes on behaving as it did, rather than
guessing.
Nothing here is wired up for you. The example is a file to read and adapt, and
an agent with neither a PORT nor a proxy behaves exactly as it always has.
Settings are read again when they change (v0.21)#
.env is read at startup and watched after it, so a key added to a running
agent reaches it at the next model call rather than at the next restart. That
matters because restarting is expensive in a way that is easy to overlook: the
live stream a board is watching drops, whatever was in flight ends with no
outcome, and the monitor forgets everything it had seen that process do. See
monitoring.md for what a re-read can and cannot reach - a port already bound
and a health check registered at startup still need the agent restarted.
This section used to say the opposite, and said it for as long as it was true.
What is already in the environment wins over the file, which is how LGStudio's
Play hands a project its own key without one being written down. Empty does
not count as set: a name exported with nothing in it is not a value anybody
chose, and letting it win meant a deployment with a perfectly good .env
starting without its key. An agent started by the monitor's runner inherits the
monitor's environment, so that is not a rare arrangement.
The one addition on a later read: the file may change, or withdraw, a value it gave last time - because that is its own value, and editing the line is exactly what somebody just did.
Every function has the same shape, so the registry can wire any of them:
export default async function issueRefund(state, { services, settings, env }) { ... }
export default function routeAmount(state, { services, settings, env }) { return "ask"; }createHandlers({ pay, limit: 10 }) replaces a service or a setting by name -
the same call a hand-written handlers.js takes, which is why a generated
registry can stand in for one.
Each unit is written from its own description and the whole contract: the
flow's purpose, every channel's type and reducer, the services and settings,
and the nodes before and after it. Text in backticks in a description is copied
exactly, so ` Answer after N research attempts ` comes out as that string.
Every unit is checked before it is kept. It must load and import nothing but
node: built-ins; then it is run once on a sample state and must write only
declared channels (lists to append channels), or - for a router - return one of
its routes. A failure goes back to the model with the reason, at most twice.
A failure is never buried. Generated code does not catch an error it cannot
put right: a service that throws, a tool call that fails, a document that will
not parse - it comes out, the run stops at that node, and the node and the
message are reported. A flow that wants to carry on says so with onError. This
is a rule because the alternative was found in the wild: a handler that wrapped
its one service call in try/catch and returned "not found" turned "the
directory is not configured" into "there is no customer with id ALFKI", with
nothing anywhere saying what had actually happened.
A service's description has to say what its default does. The host normally passes the real one in, and the default is what runs when nobody does. A description that says only what the real service does leaves the model nothing to write but a stub that throws - and a deployed agent that cannot answer a single question. Say what the demo default answers, or say plainly that it is required and only the host can provide it.
Regenerating never loses an edit. The manifest records what each file was
written from and what was written. Change a node's description and only that
node is rewritten; edit a file by hand and it is left alone - if its description
changed too, the new version is written beside it as <file>.generated.
--force overwrites anyway.
The model is gpt-5 by default; set LGFLOW_CODEGEN_MODEL or pass --model.
Serving it#
Every generated project is a chat. npm start (or node run.js) opens it in the browser: type a message, and the agent answers in the conversation, with its steps - nodes, routes, model and tool calls - folded under each reply, and Approve / Decline when a node waits for a person. One conversation is one thread: list and object channels that append or merge carry over from turn to turn, everything else starts fresh, and route limits are counted per turn. The page is src/chat.html, served at / by the same server described below.
node run.js --serve serves the agent on a local port instead of running it
once - what LGStudio's Play button starts. POST /run { input } and
POST /resume { thread, answer } each answer with a stream of JSON lines: every
node, route and fan-out as it happens, every model prompt and reply and tool
call, and last either the outputs, an approval to answer, or an error. It is
lgflow/serve (src/serve.js), so any host can drive an agent the same way.
Two things there are for hosts that serve more than one agent. createAgentServer
is the whole server; createAgentHandler is the request handler inside it,
so the page can be mounted under a path - the examples gallery mounts it at
/cancel/, and the page asks for its API by relative path, which is what makes
that work. And the createAgent a host passes in is called with the thread
the graph is being built for, beside the services and the checkpointer: a
service scoped to one conversation rather than to the whole process - a stub
standing in for a database, a per-visitor sandbox - is made by keying on it.
The same folder, as an image (0.26.2)#
A deploy writes a folder, and somebody then has to have Node, install, and keep the process up. That is right on the machine where a flow is being written and no use anywhere else: a folder cannot be handed to a platform, rolled back in one step, or started on a host that has no Node on it.
So a deploy also writes a Dockerfile and a .dockerignore:
docker build -t asker .
docker run --env-file .env -p 127.0.0.1:8080:8080 -v asker-state:/app/.lgflow askerIt is an additional route, not a replacement. The folder runs the way it
always has, with nothing installed but Node, and nobody who does not want
Docker has to have it - the Dockerfile is a file in the folder, and building it
is a choice. The install inside the image uses the vendored tarball the project
already carries, so it reaches no registry, exactly as npm install in the
folder does.
Four things in it are decisions rather than boilerplate:
.env never goes in | It is in .dockerignore and nothing adds it back. An image is the one artifact here that is copied around on purpose, and a key copied with it is in every registry, every layer cache and every docker history of it for ever - deleting it in a later layer does not remove it. The values arrive at docker run |
BIND=0.0.0.0 | Inside a container, 127.0.0.1 is the container's loopback: an agent bound to it is reachable from nothing at all, not even the machine running it. So the bind has to widen, and the boundary moves to the published port - which is why the line above publishes to 127.0.0.1 and says so. The Host check is unchanged: the agent still answers only requests addressed to it as localhost, so the way in is still a proxy, or a published port, that says so. What a wide bind gives up is the part no header replaces - a program on the same network can forge a Host header, and only the bind was stopping it |
| The state is a volume | The journal, the file store and the lock live in .lgflow. Without the mount, the history of every run disappears when the container is replaced, and that history is most of what a board can show about this agent |
| The webhook port is a separate decision | A flow with webhook triggers listens on a second port, and BIND does not open it. There is no Host check on that endpoint - it was never a page - so whatever can reach it can start the flow. TRIGGER_BIND is its own variable, commented out in both the Dockerfile and .env.example, and it belongs behind something that authenticates |
Both addresses come from src/bind.js, which both listeners read, and .env
offers PORT and BIND commented out - so a deployment that says nothing is
on loopback with a port the machine picked, exactly as every deployment before
this one.
A container also broke the folder lock, and that was fixed rather than
documented. One served agent per folder was decided by a pid and a hostname,
and in a container the folder is a volume that outlives the process, the agent
is pid 1 - so the next container's own pid is 1 too - and the hostname is
whatever that container was given. Both ways of running one read wrong: with a
stable hostname a replaced container asked alive(1), got itself, and
refused to start for ever; with a fresh one the lock was "from another machine",
which that file treated as held because it could not ask. So a holder now
writes a token, one per holding, and keeps saying when it was last there; the
heartbeat is what gets asked whenever a pid cannot be trusted. A graceful stop
still removes the file outright - a container's SIGTERM reaches the same
handler a Ctrl-C does - so the heartbeat only matters after something was
killed outright, and then it is twenty seconds rather than for ever.
When a run fails - Phase 5#
LGStudio's Play jumps to the node that failed and shows the error, the state it
was given and the line in its code. Regenerate this node rewrites that one
file with all of that attached - fixUnit in src/generate/index.js - and keeps
the old version under .lgflow/previous/; Edit code opens it in the studio.
Then Run again, with the same inputs.
Does it work?#
npm run verify:generate regenerates research-loop, word-tutor and
approval-desk from their descriptions alone, then runs their existing tests
against the generated code - the hand-written handlers.js is swapped out at
module resolution (scripts/swap) and never touched. All three pass.
Deploying a flow that delegates (v0.19)#
A flow with an agent node names capabilities, not addresses, so generating it
needs the registry that resolves them. generateProject finds a
registry.json beside the flow - or above it - before it writes anything,
and refuses the whole deploy if there is none: a project whose registry points
at files nobody put there fails on its first delegation, and writing one anyway
would be handing over something broken.
What it then carries:
agents/<name>/flow.json every agent the flow may reach, including the ones
only another agent reaches
registry.json addresses pointing at those copies
src/agents/<name>/ each agent's own generated code
src/agent.js builds the graph with that registryCode is generated for every name on an agent node's list, not only the one a
run happens to pick - which it picks is decided while running, so a project with
code for one of them fails the first time the model chooses another. An agent
that delegates in turn nests the same way, and its handlers are registered under
the key childRunner looks for:
hand_over/billing.escalate/refunds.decideRefund.
Readiness says so before you press anything: a delegating flow reports
AGENTS_NEED_A_REGISTRY, naming the agents it will need resolved.
See Many agents.
Triggers travel, but never into the flow (v0.20)#
A triggers.json sits beside the flow and says what starts it. It is not part
of the graph and never goes into flow.json - the flow must stay runnable by
hand, and a graph that described its own alarm clock would not be portable
between the places it might be started from.
Deploy copies it in:
triggers.json what starts this flow, with its "flow" rewritten
to ./flow.json - the name the copy travelled underIt is source, like flow.json and registry.json: rewritten from the
authored file every time rather than guarded as something edited here. What
starts a flow is decided where the flow is authored, and a schedule switched off
in the deployed copy that came back on at the next deploy would be worse than
being told plainly.
The host is run.js, which every way of starting the agent already goes
through - so a deployed agent keeps its own appointments, and npm start and
LGStudio's Play arm them alike. Running it once from the command line does not:
that is about this run and no other. --no-triggers serves the same agent with
nothing on a clock.
Where the webhook endpoints are reachable from is still a deployment decision:
serveTriggers is loopback unless told otherwise, TRIGGER_PORT fixes the
port, and the file holds paths rather than URLs for the same reason it holds
agent names rather than addresses.
See What starts a flow.