Symphonix Documentation · Core 0.61.0

Deployment

What the Studio writes, what it vendors, and how the folder becomes an image.

This is the engineering reference, rendered from the repository

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 it

What .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 aside

files/ 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 sayswhat happens
nothing (the default)kept until the file's own line limit — 2,000 turns
a number of daysa turn older than that is dropped, and is gone from the file
0nothing is kept at all
anything elsethe 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 Host check did not. The reason is further down, under the same folder, as an image - inside a container 127.0.0.1 is 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. The Host check 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 agent

The 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 movePORT= 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 properlydeploy/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 asker

It 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 inIt 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.0Inside 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 volumeThe 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 decisionA 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 registry

Code 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 under

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