Workflows
A workflow is a directed acyclic graph (DAG) of nodes connected by links. Each node runs code in a Docker container.
Folder structure
my-workflow/
workflow.json # DAG definition
node-a/
.node.json # node config
index.js # code
lp.js # helper
node-b/
.node.json
main.py
lp.pyworkflow.json
Defines the DAG structure:
{
"id": "my-workflow",
"name": "My Workflow",
"networks": [],
"networkDefs": [],
"nodes": [
{ "id": "node-a", "name": "Node A", "dir": "node-a" },
{ "id": "node-b", "name": "Node B", "dir": "node-b" }
],
"links": [
{ "from": "node-a", "to": "node-b" }
]
}| Field | Required | Description |
|---|---|---|
id | yes | Unique identifier |
name | yes | Display name |
networks | no | Docker networks (array; [] = default isolated, first is primary) |
networkDefs | no | Run-scoped networks to provision before the DAG and tear down after (see below) |
services | no | Sidecar containers started before the DAG and stopped after, outside the DAG flow (see below) |
nodes | yes | Array of node references (id, name, dir) |
links | no | Array of links between nodes |
.node.json
Configures a single node:
{
"id": "node-a",
"name": "Node A",
"image": "node:20-alpine",
"entrypoint": "node index.js",
"setup": ["npm install axios"],
"timeout": 10000,
"networks": [],
"inputs": null,
"outputs": null
}| Field | Required | Description |
|---|---|---|
id | yes | Unique identifier |
name | yes | Display name |
image | yes | Docker image |
entrypoint | yes | Command to run |
setup | no | Shell commands before entrypoint |
timeout | no | Node timeout in ms (0 = none) |
networks | no | Override workflow networks |
inputs | no | JSON Schema for input validation |
outputs | no | JSON Schema for output validation |
Links
Links connect nodes and control data flow:
{
"from": "node-a",
"to": "node-b",
"when": { "status": "ok" },
"data": { "extra": "value" },
"maxIterations": null
}| Field | Required | Description |
|---|---|---|
from | yes | Source node ID |
to | yes | Target node ID |
when | no | Condition on source output (see Conditions) |
data | no | Extra data merged into target input |
maxIterations | no | Loop limit for back-links |
Execution model
- Entry nodes (no incoming forward links) start first with the initial input
- Nodes in the same layer run in parallel via
Promise.all() - After a node completes, outgoing links are evaluated
- If a link has
when, it only fires if the condition matches the output - Target nodes start when all incoming links have data ready
- Multiple incoming links merge their outputs with
Object.assign() - Link
datais merged on top of the source output - If any node fails, the workflow stops
Back-links (loops)
A link that creates a cycle requires maxIterations:
{
"from": "process",
"to": "validate",
"when": { "retry": true },
"maxIterations": 3
}Without maxIterations, adding a cycle throws LinkValidationError.
Network inheritance
networksis an array of Docker network names, present on both a node and a workflow.- The first element is the primary network (Docker NetworkMode); additional elements are connected to the container after creation.
networks: [](empty array) means light-run uses its default behavior (isolated bridge). Before,network: nullplayed this role.- Use
networks: ["none"]to fully isolate a node (no network access). - If a node has
networks: []and the workflow has a non-emptynetworks, the node inherits the workflow's networks.
Run-scoped networks (networkDefs)
Everything from here down (networkDefs, services/sidecars) is advanced. Skip it for your first workflows - nodes and links are enough to build real graphs. Come back when you need a node to reach a database, a proxy, or a provider behind a secret.
A bare name in networks must already exist on the light-run host, or the run fails. networkDefs lets a workflow declare networks it wants created for the duration of a single run and destroyed afterwards, so you never manage Docker networks by hand.
{
"id": "scrape-wf",
"name": "Scrape",
"networkDefs": [{ "alias": "proxy-net", "iccEnabled": true }],
"nodes": [
{ "id": "fetch", "name": "Fetch", "dir": "fetch", "networks": ["proxy-net"] }
]
}| Field | Required | Description |
|---|---|---|
alias | yes | The simple name you reference in networks. |
iccEnabled | no | Inter-container communication. Defaults to true (containers on the network can reach each other, required to reach a proxy/service on the same network). Set false to isolate them. |
How it works:
- Before the DAG starts, the executor creates a real Docker network named
lp-<runId>-<alias>(via light-runPOST /networks) for eachnetworkDef. - Every
networksentry that matches analiasis rewritten to that real name when the node runs, so two concurrent runs of the same workflow get isolated networks. - In the
finallyafter the DAG, the executor deletes only the networks it created (viaDELETE /networks). Teardown is best-effort and never aborts the result. - A bare name in
networkswith no matchingnetworkDefis left untouched: it is assumed to pre-exist and is never torn down. This is how you attach to a long-lived shared network (for example an always-on proxy you started yourself).
Isolation is topological: put each group of nodes on its own networkDef and they can only reach services on that same network.
Services (sidecars)
A service is a long-running container started before the DAG and stopped after it, living outside the DAG flow (it is never a node and never an entry node). The canonical use is a network proxy that holds a provider API key so a workload node can reach another provider by hostname without the secret ever entering the workload container.
{
"id": "claude-wf",
"name": "Claude via proxy",
"networkDefs": [{ "alias": "llm-net" }],
"services": [
{
"id": "llm-proxy",
"image": "ghcr.io/example/llm-proxy:latest",
"networks": ["llm-net"],
"env": ["ANTHROPIC_API_KEY"],
"ready": { "log": "listening on 8080" }
}
],
"nodes": [
{
"id": "agent",
"name": "Agent",
"dir": "agent",
"networks": ["llm-net"],
"providesEnv": { "ANTHROPIC_BASE_URL": "http://${service:llm-proxy}:8080" }
}
]
}| ServiceDef field | Required | Description |
|---|---|---|
id | yes | Referenced by nodes via ${service:<id>}. |
image | yes | Container image (its own entrypoint runs unless entrypoint is set). |
networks | no | Networks to attach (aliases resolved like node networks). |
env | no | Host env var names injected into the service container (e.g. the API key). Resolved from process.env then the nearest .env; values are never serialized. |
files | no | Extra files written into the container (e.g. a proxy config). |
setup | no | Build steps before the entrypoint. |
entrypoint | no | Command override. |
timeout | no | Timeout in ms (0/omitted = none). |
ready | no | Readiness gate. { "log": "..." } waits for that line in the service logs (30s cap); omitted falls back to a short fixed delay. |
How it works:
- After networks are provisioned, each service is started detached (light-run
POST /run). Its light-run id is the container name, which is its DNS hostname on the attached networks. - The executor waits for readiness, then runs the DAG.
- A node's
providesEnvis literal env injected into that node; every${service:<id>}placeholder is replaced with the service's hostname. SoagentreceivesANTHROPIC_BASE_URL=http://llm-proxy-<container>:8080whileANTHROPIC_API_KEYstays only in the proxy. - In the
finally, services are stopped (POST /runs/:id/stop) before networks are deleted (a network with a still-running container would refuse deletion).
Multiple services on multiple networks give you per-group isolation for free: put nodes 1-3 on net-a (reaching proxy-a), nodes 4-6 on net-b (reaching proxy-b), and so on. Each group can only reach the proxy on its own network.
Two service modes follow from this:
- Per-run service (above): declared in
services[], started and torn down with the run. - Always-on shared service: a proxy you run yourself on a persistent named network. Workflows reference that network as a bare name in
networks(no matchingnetworkDef), so light-process never tears it down.
Data flow
Input -> [Node A] -> output A
|
v (merged with link.data)
[Node B] -> output B
|
v
[Node C] -> final outputWhen multiple nodes feed into one:
[Node A] -> output A -+
|-> merged input -> [Node C]
[Node B] -> output B -+Merge order follows link evaluation order. Later values overwrite earlier ones.