light-process

Workflows

A workflow is a directed acyclic graph (DAG) of nodes connected by links. Each node runs code in a Docker container.

when ok:truefan-outwhen score > 0.8fetchnode:24parsepythonstorenode:24reportpython
Fig. - nodes fan out; a link fires only when its condition holds.data + conditions

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

workflow.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" }
  ]
}
FieldRequiredDescription
idyesUnique identifier
nameyesDisplay name
networksnoDocker networks (array; [] = default isolated, first is primary)
networkDefsnoRun-scoped networks to provision before the DAG and tear down after (see below)
servicesnoSidecar containers started before the DAG and stopped after, outside the DAG flow (see below)
nodesyesArray of node references (id, name, dir)
linksnoArray 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
}
FieldRequiredDescription
idyesUnique identifier
nameyesDisplay name
imageyesDocker image
entrypointyesCommand to run
setupnoShell commands before entrypoint
timeoutnoNode timeout in ms (0 = none)
networksnoOverride workflow networks
inputsnoJSON Schema for input validation
outputsnoJSON Schema for output validation

Links connect nodes and control data flow:

{
  "from": "node-a",
  "to": "node-b",
  "when": { "status": "ok" },
  "data": { "extra": "value" },
  "maxIterations": null
}
FieldRequiredDescription
fromyesSource node ID
toyesTarget node ID
whennoCondition on source output (see Conditions)
datanoExtra data merged into target input
maxIterationsnoLoop limit for back-links

Execution model

  1. Entry nodes (no incoming forward links) start first with the initial input
  2. Nodes in the same layer run in parallel via Promise.all()
  3. After a node completes, outgoing links are evaluated
  4. If a link has when, it only fires if the condition matches the output
  5. Target nodes start when all incoming links have data ready
  6. Multiple incoming links merge their outputs with Object.assign()
  7. Link data is merged on top of the source output
  8. If any node fails, the workflow stops

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

  • networks is 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: null played this role.
  • Use networks: ["none"] to fully isolate a node (no network access).
  • If a node has networks: [] and the workflow has a non-empty networks, 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"] }
  ]
}
FieldRequiredDescription
aliasyesThe simple name you reference in networks.
iccEnablednoInter-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-run POST /networks) for each networkDef.
  • Every networks entry that matches an alias is rewritten to that real name when the node runs, so two concurrent runs of the same workflow get isolated networks.
  • In the finally after the DAG, the executor deletes only the networks it created (via DELETE /networks). Teardown is best-effort and never aborts the result.
  • A bare name in networks with no matching networkDef is 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.

network: lp-<run>-svc-nethttp://proxyno secret+ Bearer keyworkloadno API key in envproxyholds the API keyexternal APIapi.example.com
Fig. - the workload reaches the API by hostname; the key never leaves the proxy.secret isolation
{
  "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 fieldRequiredDescription
idyesReferenced by nodes via ${service:<id>}.
imageyesContainer image (its own entrypoint runs unless entrypoint is set).
networksnoNetworks to attach (aliases resolved like node networks).
envnoHost 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.
filesnoExtra files written into the container (e.g. a proxy config).
setupnoBuild steps before the entrypoint.
entrypointnoCommand override.
timeoutnoTimeout in ms (0/omitted = none).
readynoReadiness gate. { "log": "..." } waits for that line in the service logs (30s cap); omitted falls back to a short fixed delay.

How it works:

  1. 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.
  2. The executor waits for readiness, then runs the DAG.
  3. A node's providesEnv is literal env injected into that node; every ${service:<id>} placeholder is replaced with the service's hostname. So agent receives ANTHROPIC_BASE_URL=http://llm-proxy-<container>:8080 while ANTHROPIC_API_KEY stays only in the proxy.
  4. 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 matching networkDef), 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 output

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

On this page