Lightweight DAG workflow engine for orchestrating code in Docker containers. Delegates container execution to light-run.
Documentation - Quick Start - Use Cases - npm
Per-tenant report pipelines - SaaS sending a PDF per customer: fetch data (Python) -> transform (R) -> render PDF (Node) -> send email. One POST /run per customer, full Docker isolation per step, no shared state. Temporal is too heavy, n8n has no real container execution.
Security automation chains - Run Trivy, nuclei, nmap in sequence with conditional branching: if a CVE is found, trigger a deeper scan. Each scanner in its own container, self-hosted, HTTP-triggered. No GitHub Actions cloud dependency, no Bash fragility.
Polyglot processing pipelines - Node scraper, Python parser, Go enricher, shell uploader - all chained. No virtualenvs, no global installs, no runtime conflicts. Each step gets only the deps it needs. Mixed runtimes that LangChain and Prefect cannot handle natively.
Run untrusted code - Execute user-submitted scripts in containers isolated upstream by light-run (dropped caps, PID limits, isolated network, optional gVisor). Perfect for coding playgrounds, graders, or plugin systems where you cannot trust the input.
light-process is the DAG orchestrator. Container execution is delegated to a separate service called light-run. Install both:
npm install -g light-process @enixcode/light-runRequires Node 20+ and Docker running on the same host as light-run.
Prefer a project-local install? Drop the -g and call the CLI through npx (the binaries land in node_modules/.bin, not your PATH, so plain light will not resolve):
npm install light-process @enixcode/light-run
npx light doctorAlpha snapshots from GitHub
npm install -g github:enixCode/light-process#alpha
npm install -g github:enixCode/light-run#alphaThe #alpha variant installs the most recent commit on main. It always reflects the latest code, unlike npm packages which only update on tagged releases.
If you use Claude Code, light-process ships a plugin with skills covering the architecture, workflow scaffolding, node creation, remote management, and runner setup. Install via the public marketplace:
/plugin marketplace add enixCode/plugins
/plugin install light-process@enixThe plugin lives in .claude-plugin/plugin.json + skills/ of this repo. Skills load on demand based on what you ask Claude (no overhead until a relevant request).
One command. light serve auto-spawns a local light-run when LIGHT_RUN_URL is unset:
light serve # boots light-run + the orchestrator, streams both logsOr explicitly, with the runner on a separate host (prod):
# Host 1 - the runner
light-run serve --token $(openssl rand -hex 32) --port 3001
# Host 2 - the orchestrator
export LIGHT_RUN_URL=http://runner-host:3001
export LIGHT_RUN_TOKEN=<same token as above>
light serveOther useful commands:
light doctor # check Node + light-run connectivity
light init my-project # scaffold a project
cd my-project
light run example # run the example workflowlight serve --install will also npm i -g @enixcode/light-run for you if the binary is missing.
Output:
Running: Example (from folder)
> Hello
[Hello] Input: {}
[ok] Hello 2100ms
-> {"hello":"world","input":{}}
[ok] 2108ms
After light init my-project, you get this:
my-project/
example/
workflow.json # the DAG: which nodes exist, how they link
hello/
.node.json # node config (Docker image, entrypoint, I/O)
index.js # your code
lp.js # helper - provides input and send()
lp.d.ts # auto-generated types for editor autocomplete
A node is just a folder with code that runs in a Docker container. A workflow is a workflow.json that wires nodes together. That's it.
Open example/hello/index.js:
const { input, send } = require('./lp');
console.error('Input:', JSON.stringify(input));
send({ hello: 'world', input });input- the JSON data passed in (from--inputor a previous node)send(obj)- your node's output, passed to the next nodeconsole.error(...)- logs (stdout is reserved for the helper)
Try editing it:
const { input, send } = require('./lp');
send({ greeting: `Hello, ${input.name || 'stranger'}!` });Then run it with input:
light run example --input '{"name": "Alice"}'
# -> {"greeting":"Hello, Alice!"}To add a second node that uses the first node's output, cd into the workflow folder first so the new node is auto-registered in workflow.json:
cd example
light init --node shout # creates example/shout/ and registers it
cd ..
light link example --from hello --to shout # wire hello -> shoutNow you have a two-node pipeline. Run light describe example to visualize it.
Note:
light init --nodeonly auto-registers the node if its parent directory contains aworkflow.json. Outside a workflow folder, the node is created standalone and you'll see a hint in the output.
Stuck? Run light doctor to check your environment (Node + light-run).
- DAG workflows - nodes run in parallel when possible, linked with conditions
- Delegated execution - containers run on a light-run service (isolation, caps, gVisor handled upstream)
- REST API - serve workflows over HTTP for remote clients
- Multi-language - JavaScript and Python out of the box, any language via Docker
- Schema validation - JSON Schema for inputs/outputs on every node
- Conditional routing - MongoDB-style
whenclauses on links - Loop support - back-links with
maxIterationsfor retry/iteration patterns - CLI + SDK - use from terminal or programmatically in Node.js
| Command | Description |
|---|---|
light run <target> |
Execute a workflow or single node |
light serve [dir] |
Start the REST API server |
light init [dir] |
Scaffold a new project or node |
light check <target> |
Validate workflow structure |
light describe <target> |
Visualize the DAG with schemas (text + Mermaid) |
light list |
List workflows in a directory |
light pack <target> |
Convert workflow folder to JSON |
light unpack <target> |
Convert JSON to workflow folder |
light link <dir> |
Manage links (inline flags or open in $EDITOR) |
light node schema <dir> |
Edit a node's input/output JSON Schema |
light node helpers <dir> |
Regenerate lp.d.ts from schema |
light node dev <dir> |
Generate .devcontainer/devcontainer.json (auto-loads nearest .env) and open VS Code |
light remote <subcommand> |
Manage remote profiles (bind, set-key, ls, run, ...) |
light pull <id> |
Pull a workflow from a remote server |
light push [name] |
Push local workflow(s) to a remote server |
light doctor |
Check environment |
All commands search the current directory by default.
# Run a workflow from folder
light run my-workflow
# Run with input data
light run my-workflow --input '{"name": "Alice", "count": 5}'
# Run from a JSON file
light run my-workflow --input data.json
# Get full JSON output
light run my-workflow --json
# Run a single node (reads .node.json in current dir)
light run --node my-node
# Set a timeout (30 seconds)
light run my-workflow --timeout 30000light serve --port 3000Exposes a small REST API used by light remote, light pull, and light push to drive a remote instance.
API key authentication is opt-in. Set LP_API_KEY to enable Bearer auth on write routes and /api/*. If unset, auth is disabled and all routes are public:
LP_API_KEY=my-secret-key light serve --port 3000| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /health |
public | Health check |
| GET | /api/workflows |
required | List workflow summaries |
| GET | /api/workflows/:id |
required | Get workflow detail (add ?full=true for full JSON) |
| POST | /api/workflows |
required | Register a workflow (add ?persist=true to write to disk) |
| PUT | /api/workflows/:id |
required | Replace a workflow (add ?persist=true) |
| DELETE | /api/workflows/:id |
required | Remove a workflow (add ?persist=true to delete file) |
| POST | /api/workflows/:id/run |
required | Execute a workflow with the JSON body as input |
Examples:
# Health check (no auth required)
curl http://localhost:3000/health
# List workflows
curl -H "Authorization: Bearer <key>" http://localhost:3000/api/workflows
# Run a workflow
curl -X POST -H "Authorization: Bearer <key>" \
-H "Content-Type: application/json" \
-d '{"name":"Alice"}' \
http://localhost:3000/api/workflows/my-wf/run# Scaffold a full project
light init my-project
# Scaffold a single JavaScript node
light init --node my-node
# Scaffold a Python node
light init --node my-node --lang pythonWorkflows exist in two formats:
- Folder (for editing) - a directory with
workflow.json+ one subfolder per node. This is what you edit, git, and push to a server - JSON (for transport) - a single portable file with everything embedded. Used by the API and for sharing
Convert between them with pack and unpack:
# Folder -> JSON (removes the folder)
light pack example
# JSON -> Folder (removes the JSON)
light unpack example
# Keep the source after converting
light pack example --keep
# List all workflows
light list
light list --jsonmy-project/
order-pipeline/
workflow.json # DAG definition
validate/
.node.json # node config
index.js # your code
lp.js # helper (auto-generated)
lp.d.ts # types for autocomplete (auto-generated)
process/
.node.json
main.py
lp.py
notify/
.node.json
index.js
lp.js
main.js # SDK entry point (optional)
{
"id": "order-pipeline",
"name": "Order Pipeline",
"networks": [],
"networkDefs": [],
"nodes": [
{ "id": "validate", "name": "Validate", "dir": "validate" },
{ "id": "process", "name": "Process", "dir": "process" },
{ "id": "notify", "name": "Notify", "dir": "notify" }
],
"links": [
{ "from": "validate", "to": "process", "when": { "valid": true } },
{ "from": "process", "to": "notify" }
]
}{
"id": "validate",
"name": "Validate",
"image": "node:20-alpine",
"entrypoint": "node index.js",
"setup": [],
"timeout": 10000,
"networks": [],
"inputs": {
"type": "object",
"properties": {
"orderId": { "type": "string" }
},
"required": ["orderId"]
},
"outputs": {
"type": "object",
"properties": {
"valid": { "type": "boolean" }
}
}
}// index.js
const { input, send } = require('./lp');
console.error('Processing order:', input.orderId);
const result = { valid: true, orderId: input.orderId };
send(result);# main.py
from lp import input, send
import sys
print('Processing order:', input.get('orderId'), file=sys.stderr)
result = {'valid': True, 'orderId': input.get('orderId')}
send(result)Read JSON from stdin, write result to .lp-output.json:
#!/bin/sh
INPUT=$(cat)
echo "Got: $INPUT" >&2
echo '{"done": true}' > .lp-output.jsonimport { Workflow, Node, Schema, LightRunClient } from 'light-process';
// Create workflow
const wf = new Workflow({ name: 'greeting-pipeline' });
// Node 1: greet
const greet = wf.addNode({ name: 'Greet', image: 'node:20-alpine' });
greet.inputs = Schema.object({ name: Schema.string() }, ['name']);
greet.setCode((input) => ({ message: `Hello, ${input.name}!` }));
// Node 2: uppercase
const upper = wf.addNode({ name: 'Uppercase', image: 'node:20-alpine' });
upper.setCode((input) => ({ result: input.message.toUpperCase() }));
// Connect them
wf.addLink({ from: greet.id, to: upper.id });
// Run
const result = await wf.execute({ name: 'World' }, { runner: new LightRunClient() });
console.log(result.results);
// { "greet-id": { output: { message: "Hello, World!" } },
// "upper-id": { output: { result: "HELLO, WORLD!" } } }// Route based on output values
wf.addLink({
from: validate.id,
to: process.id,
when: { status: 'ok', score: { gte: 80 } }
});
wf.addLink({
from: validate.id,
to: reject.id,
when: { status: { ne: 'ok' } }
});// Retry up to 3 times
wf.addLink({
from: process.id,
to: validate.id,
when: { retry: true },
maxIterations: 3
});import { loadWorkflowFromFolder, LightRunClient } from 'light-process';
const wf = loadWorkflowFromFolder('./my-workflow');
const result = await wf.execute({ key: 'value' }, { runner: new LightRunClient() });import { Node, loadDirectory, DEFAULT_IGNORE } from 'light-process';
const node = new Node({ name: 'My Node', image: 'node:20-alpine' });
node.addFolder('./my-node', 'node index.js', { ignore: DEFAULT_IGNORE });Links support MongoDB-style when conditions on the source node's output:
| Operator | Example | Description |
|---|---|---|
| (none) | { status: "ok" } |
Exact match |
gt |
{ count: { gt: 5 } } |
Greater than |
gte |
{ count: { gte: 5 } } |
Greater or equal |
lt |
{ count: { lt: 10 } } |
Less than |
lte |
{ count: { lte: 10 } } |
Less or equal |
ne |
{ status: { ne: "error" } } |
Not equal |
in |
{ role: { in: ["admin", "mod"] } } |
Membership |
exists |
{ token: { exists: true } } |
Field presence |
regex |
{ token: { regex: "^ok" } } |
Regex match |
or |
{ or: [{...}, {...}] } |
Logical OR |
All top-level fields use AND logic by default.
Container execution is delegated to a light-run HTTP service. light-process never touches Docker itself.
# Point light-process at a running light-run instance
export LIGHT_RUN_URL=http://localhost:3001
export LIGHT_RUN_TOKEN=your-bearer-token # optional, if light-run requires auth// SDK: point the client explicitly if env vars aren't set
const runner = new LightRunClient({
url: 'http://localhost:3001',
token: process.env.LIGHT_RUN_TOKEN,
});Isolation (cap drops, PID limits, network, gVisor runtime, GPU access) is configured on the light-run service - see its README. A node's networks array (["none"], named networks, etc.) is forwarded to light-run; a workflow's networkDefs are provisioned per run and torn down afterwards.
import { Schema } from 'light-process';
// Define input/output schemas on nodes
node.inputs = Schema.object({
name: Schema.string({ minLength: 1 }),
age: Schema.integer({ minimum: 0 }),
tags: Schema.array(Schema.string(), { minItems: 1 }),
active: Schema.boolean(),
}, ['name', 'age']); // required fields
node.outputs = Schema.object({
result: Schema.string(),
score: Schema.number({ minimum: 0, maximum: 100 }),
});light-process is instrumented end to end with OpenTelemetry. With no env vars set, the SDK is constructed but never started, so there is zero runtime cost. Set one of the env vars below to turn it on.
| Env var | What it does |
|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318 |
Send traces + metrics via OTLP/HTTP to that URL (any backend: Tempo, Jaeger, Honeycomb, Grafana Cloud, SigNoz, Datadog, ...). |
LP_OTEL_DEBUG=1 |
Print spans to stdout via ConsoleSpanExporter. No backend, no Collector needed. Smoke-test mode. |
OTEL_SERVICE_NAME=my-service |
Override the default light-process service name. |
Spans emitted:
workflow.execute workflow.id, workflow.name, workflow.node_count, workflow.success, workflow.duration_ms
+ node.run node.id, node.name, node.image, node.exit_code, node.duration_ms, node.success
+ http.client.* from @opentelemetry/auto-instrumentations-node (outbound call to light-run)
When light-run is also instrumented (>= v0.3.0), the trace continues into the HTTP server span, then into docker.run and the per-container spans emitted by light-runner (>= v0.13.0). The full chain shares a single trace_id.
See docs/superpowers/specs/2026-05-12-otel-integration-design.md for the full design.
git clone https://github.com/enixcode/light-process.git
cd light-process
npm install
npm run build # compile TypeScript
npm run dev # watch mode
npm run link # build + npm link for local CLI testing
npm test # unit tests
npm run test:all # unit + integration tests- Node.js >= 20
- A running light-run instance reachable via
LIGHT_RUN_URL