Lightflow is a lightweight, local-first workflow compiler and execution
engine that gives AI agents a structural backbone for multi-step
workflows—turning steps an agent would otherwise follow from prose instructions
into an explicit DAG of python_action stages and operator_action checkpoints
with dependencies, retries, and rollbacks enforced by the engine.
Every step runs across process boundaries as a plain CLI command that an agent
or operator can start, pause, and resume against a single append-only JSON
ledger (passport.json). Rather than burying "ask before step 7" inside a
long prompt blob where context decay and momentum cause agents to skip ahead,
Lightflow halts the process (exit 2) and delivers each checkpoint as a fresh,
dedicated turn at the exact moment of decision. It provides zero-daemon,
append-only state guardrails in pure Python—depending only on PyYAML and
jsonschema, with built-in standard-library fallbacks when running directly
from a checkout.
flowchart LR
S1["1. fetch_top_stories<br/>(python_action + retry)<br/>Fetch live API data"] --> S2{"2. editorial_gate<br/>(operator_action · exit 2)<br/>Human/Agent approval + JSON Schema"}
S2 -->|payload.outputs.editorial_gate.approved == true| S3["3. publish_digest<br/>(python_action)<br/>Idempotent side effect"]
-
Append-Only Passport Ledger (
passport.json): Every stage transition appends an immutableStamp(PENDING,COMPLETED,PAUSED,SKIPPED,FAILED) guarded by cross-platform file locks and atomicos.replace, recording operator and agent session provenance (LIGHTFLOW_OPERATOR/ANTIGRAVITY_CONVERSATION_ID). -
Selective Subgraph Re-Arming: When a stage fails, fix the underlying
code or environment and run
resume. Lightflow re-arms only the failed stage and its downstream collateral subgraph—never re-executing completed upstream side effects. -
Human-in-the-Loop Checkpoints (
operator_action): Suspends execution with exit code2so the gate arrives as its own dedicated turn rather than a buried prompt instruction, validates operator payloads against JSON Schema, and auto-generates drift-freeresumecommands. -
Safe, CEL-like Expressions:
run_if, polling conditions, and dynamic gate instructions use a small subset of CEL syntax (string(),int(),size(),has()), evaluated by a pure-Python AST walker without native C/Go dependencies or unsafeeval(). -
YAML & JSON Manifests: Author workflows declaratively in YAML
(
.yaml) or JSON (.json). -
Zero-Dependency Interactive Visualizer (
visualizer.html): Generates a standalone, single-file HTML5 DAG canvas, execution trace log, and payload/manifest inspector with zero external CDN requests. -
First-Class Agent Integrations:
-
Model Context Protocol (MCP): Built-in stdio MCP server
(
lightflow-mcp) for Claude Code, Claude Desktop, Gemini CLI, and Cursor, plusAGENTS.mdandCLAUDE.md. -
Agent Skills & Streaming Notifications: Includes
skills/run_lightflows/SKILL.md(executing and resuming workflows),skills/create_lightflows/SKILL.md(authoring new workflows), andbenchmarks/SKILL.md, plus optional milestone notifications when running insideagentapi-compatible hosts.
-
Model Context Protocol (MCP): Built-in stdio MCP server
(
-
Compact Context Footprint (Benchmarks):
Intermediate stage outputs (
payload.outputs.<stage>) flow between Python actions on disk viapassport.jsonrather than through toolstdout, so the agent only sees 1-line stage status summaries and active gate prompts (~250–1,700 tokensend-to-end across the 6 included workflows, and$3.1\times$ fewer agent-generated tool-input tokens than an unstructured run where the LLM re-serializes intermediate state across turns).
Reach for Lightflow when all of these are true:
- The task has several ordered steps with real side effects (modifying files, calling external APIs, publishing artifacts) that must not be repeated on a retry.
- You want a human or an agent to inspect intermediate output and approve a
checkpoint partway through (
operator_action). - You want the run to be inspectable on disk (
passport.json) and resumable across separate CLI invocations or compacted agent sessions.
Look elsewhere when:
- You need concurrent execution. Execution is strictly single-threaded and evaluates stages sequentially in deterministic topological order.
- You need unattended scheduling. Lightflow does not include a cron
daemon; trigger
lightflow startfromcron,systemd, or CI if you need a recurring run. - You need shared multi-user state. State is a local file guarded by an
advisory lock (
flock/msvcrt), not a networked service. - It is a single step. Just call the function directly.
| Category | Representative Tools | Infrastructure | Execution Model | Where Lightflow Differs |
|---|---|---|---|---|
| Distributed Orchestrators | Temporal, Prefect, Airflow | Server daemon + database | Distributed workers | Zero daemon or DB; state is a single atomic local JSON ledger (passport.json). |
| Cyclic Agent Graphs | LangGraph, Burr | In-process Python library | Cyclic state machines | Process-boundary suspension (exit 2) with auto-generated CLI resume commands and AST-restricted CEL-like conditions. |
| Function / Task DAGs | Hamilton, doit, Kedro | Local Python library | Single-run in-memory DAG | Built-in operator_action JSON Schema gates, rollback_action hooks, and append-only Stamp history. |
git clone https://github.com/google/lightflow.git
cd lightflow
pip install -e .(Or run directly from a checkout without installing dependencies using
PYTHONPATH=. python3 -m lightflow ...)
Each run's passport.json is written to
~/.lightflow/lightflow_state_<log_id>/passport.json by default. Set
LIGHTFLOW_STATE_DIR=/some/dir to relocate the lightflow_state_<log_id>/
directories (for example into a project-local or CI-scoped folder). Gate and
re-arm stamps attribute the action to --operator if passed, LIGHTFLOW_OPERATOR
if set, or <user> (agent:<ANTIGRAVITY_CONVERSATION_ID>) when invoked inside an
agent session so you can trace any approval back to its conversation transcript.
python_import executes arbitrary Python by design — treat manifests as
code and review them like you would a script. For manifests authored by an
agent, set LIGHTFLOW_ALLOWED_IMPORT_PREFIXES to a comma-separated list of
module prefixes (e.g.
LIGHTFLOW_ALLOWED_IMPORT_PREFIXES=my_pkg.actions,examples) and the engine will
refuse to import any action outside those prefixes, while disabling
manifest-local sys.path injection and module eviction so an untrusted manifest
directory cannot shadow allowlisted packages.
Run the included live Hacker News curation workflow right out of the box:
# 1. Dry-run simulation (validates DAG topology, CEL-like expressions & JSON Schemas)
lightflow dry_run --lightflow=examples/hn_digest/lightflow.yaml
# 2. Start execution (fetches live HN top stories, then suspends at 'editorial_gate' with exit code 2)
lightflow start --lightflow=examples/hn_digest/lightflow.yaml --log_id=hn_demo
# 3. Approve the human gate and finish the workflow (--log_id remembers the manifest)
lightflow resume --log_id=hn_demo \
--stage=editorial_gate --resolution=APPROVE \
--payload='{"editor_note": "Top engineering reads today.", "output_path": "/tmp/hn_digest.md"}'
# 4. Generate a standalone interactive HTML5 DAG & timeline visualizer
lightflow visualize --log_id=hn_demo --output=/tmp/viz.htmlpython3 -m lightflow ... is equivalent to lightflow ... whenever the console
script is not on your PATH. On shells that strip inner double quotes (such as
Windows PowerShell 5.1) or when passing larger JSON objects, you can pass
payloads from a file or stdin via --payload=@payload.json, --payload=@-, or
--payload_file=payload.json.
| # | Example | Pattern Demonstrated | External API / Mechanism |
|---|---|---|---|
| 1 | examples/hn_digest/ |
Live API → Human Editorial Gate → Idempotent Publish: Fetches top stories, previews the #1 story in the gate prompt, and writes a Markdown digest. | Hacker News Firebase API (zero auth) |
| 2 | examples/usgs_seismic_alert/ |
Live Telemetry Triage: Polls 24h M4.5+ global seismic events, pauses for severity classification (INFO / ADVISORY / WATCH), and emits a bulletin. |
USGS GeoJSON Feed (zero auth) |
| 3 | examples/pypi_upgrade_guard/ |
Live Audit → Approval Gate → Automatic rollback_action: Compares pinned packages against live PyPI, applies upgrades, and automatically restores .bak if smoke tests fail. |
PyPI JSON API (zero auth) |
| 4 | examples/async_job_watcher/ |
Async Background Polling & ALL_DONE Cleanup: Spawns a 10–20s background process, polls its status file via polling_policy until READY, and cleans up temp files. |
Detached Worker + Local JSON Status File |
| 5 | examples/create_lightflow/ |
Staggered Agent Skill Delivery ("A Lightflow to Create Lightflows"): 3 human/agent design & coding gates interleaved with deterministic AST, unittest, and dry_run verification stages. |
Python ast + unittest + LightflowEngine |
| 6 | examples/tenant_gitops_onboarding/ |
22-Stage Multi-Branch Kubernetes, Cloud IAM & GitOps Onboarding: 4-phase orchestration with 3 branching patterns (role-based early handoff, 3-way IAM status fan-out + ALL_DONE join, parallel S3/Vault + compliance-tier KMS diamond), 2 human gates, 2 GitOps PR merge loops, and ArgoCD sync polling. |
Local Mocked GitOps / Cloud IAM / SCIM / KMS / ArgoCD Sandbox |
Each python_action is a regular Python function that receives a copy of the
current payload dictionary (plus any static_kwargs from the manifest) and
returns either (payload_delta_dict, message_str) or just payload_delta_dict
(which Lightflow merges into payload and records under
payload.outputs.<stage_name>):
from typing import Any
def fetch_rows(
payload: dict[str, Any], limit: int = 500, dry_run: bool = False
) -> tuple[dict[str, Any], str]:
if dry_run or payload.get("dry_run"):
return {"count": 0, "summary": "0 rows (dry run)"}, "[DRY RUN] Skipped fetch"
return {"count": limit, "summary": f"{limit} rows"}, f"Fetched {limit} rows"
def publish_report(payload: dict[str, Any]) -> tuple[dict[str, Any], str]:
summary = payload["outputs"]["fetch"]["summary"]
return {"published": True}, f"Published report ({summary})"name: daily_revenue_report
description: Fetch rows, wait for human approval, and publish report.
actions:
- id: fetch_rows
python_import: my_pkg.actions.fetch_rows
- id: publish_report
python_import: my_pkg.actions.publish_report
stages:
- name: fetch
description: Fetch daily rows
python_action:
action_id: fetch_rows
static_kwargs:
limit: 500
retry_policy:
max_attempts: 3
- name: approve
description: Human review gate
run_after:
- fetch
operator_action:
instructions: "'Ready to publish: ' + payload.outputs.fetch.summary"
json_schema:
type: object
required: [approved]
properties:
approved:
type: boolean
- name: publish
description: Publish verified report
run_after:
- approve
run_if: "payload.outputs.approve.approved == true"
python_action:
action_id: publish_report| Stage Field | Purpose |
|---|---|
run_after |
Upstream stage names that must finish before this stage evaluates. |
run_if |
CEL-like expression evaluated against payload; stage is stamped SKIPPED if false. With top-level strict_expressions: true, selecting an absent key fails the stage instead of yielding null, naming the key and the closest match (guard optional keys with has()). |
trigger_rule |
ALL_SUCCESS (default), NONE_FAILED (runs once upstreams finished and none failed — the join after complementary run_if branches), or ALL_DONE (runs even after an upstream failure; cleanup stages). |
timeout_seconds |
Bounds each attempt (0–86400s; 0 disables the bound) using a background worker thread (shown in dry_run). |
retry_policy |
Opt-in retry (max_attempts, initial_backoff_seconds, backoff_multiplier) with ±10% jitter for exceptions and timeouts. |
polling_policy |
Polls poll_tick_action (mutually exclusive with python_action) until condition evaluates to true (interval_seconds, timeout_seconds, max_attempts); each tick's dict is merged into payload.outputs.<stage> before condition runs, and tick messages are persisted to passport.json. |
rollback_action |
Compensating python_action invoked automatically after a stage exhausts its retries; its output delta is merged into payload. |
- Stage Output Namespacing & Collision Detection: Stage outputs land both
under
payload.outputs.<stage>and at the top level ofpayload(last writer wins;outputsis a reserved top-level key). The engine warns⚠️ [stage] overwrote top-level payload.<key>when a later stage changes a key an earlier one set and some expression readspayload.<key>(two stages sharing a reusable action is otherwise silent); preferpayload.outputs.<stage>.<key>in expressions and checkstatus --verbosefor per-key provenance. - Actionable
dry_runDiagnostics:dry_runends with aSummary:line (including❌ N would failfor expressions that would error at runtime), a ready-to-paste💡 --payloadhint when a branch reads unseeded keys, and thestage→actionpairs that lackdry_runsupport. - Run Discovery & Remembered Manifests:
lightflow list(and thelist_lightflow_runsMCP tool) enumerates saved runs with the manifest each was started from. Runs record their manifest inpassport.json, sostatus,resume,visualize, andcleanupneed only--log_id=<id>; if the manifest moves, pass--lightflow=<new path>once and it is re-recorded. - Safe Re-Arming & Force-Start Summary:
start --forceprints what it discards (completed stages, paused gate, history) before wiping. AnALL_DONEstage that ran because an upstream failed or a gate was rejected is automatically re-armed (↻ cleanup re-armed …) when that cause is re-armed or the gate re-opened, so it runs again at the real end of the run. - Milestone Push Notifications (
agentapi): Agent push notifications viaagentapiare disabled by default (LIGHTFLOW_NOTIFY=off) so synchronous CLI/MCP runs do not double-notify a caller that already reads stdout/JSON; setLIGHTFLOW_NOTIFY=milestones(for detached/background runs) to push only gate suspensions, failures, rejections, rollbacks, and the final outcome, orLIGHTFLOW_NOTIFY=allfor every stage transition.
Expression Evaluator (run_if, polling_policy.condition,
operator_action.instructions):
- Operators:
&&,||,!,==,!=,<,<=,>,>=,+,-,*,/ - Built-ins:
size(x)/len(x),has(x.field),string(x),int(x),double(x),bool(x)(all null-safe:string(null) == "",int(null) == 0) - String & Collection Methods:
contains(),startsWith(),endsWith(),replace(),split(),strip(),lower(),upper() - Per-Stage Output Namespacing: Every stage's delta is merged into
payloadand also recorded underpayload.outputs.<stage_name>to prevent key collisions across stages.
Each stage carries zero or more stamps in the passport. A stage's current state is its most recent stamp; earlier stamps are retained as an audit log.
stateDiagram-v2
[*] --> Unstamped: never reached
Unstamped --> PENDING: engine starts stage
PENDING --> COMPLETED: action returned
PENDING --> FAILED: action raised (retries exhausted)
PENDING --> PAUSED: operator_action reached (exit 2)
Unstamped --> SKIPPED: run_if false / upstream skipped
Unstamped --> FAILED: upstream failed (ALL_SUCCESS)
PAUSED --> COMPLETED: resume --resolution=APPROVE
FAILED --> PENDING: start/resume re-arms subgraph
COMPLETED --> [*]
When a stage fails:
- The failing stage is stamped
FAILEDwith its traceback, and itsrollback_actionruns if configured. - Downstream dependents under
ALL_SUCCESSare stampedFAILEDas collateral (Upstream dependency failed), while independent parallel branches in a diamond continue to completion andALL_DONEcleanup stages still execute. - Running
lightflow resumeappends a freshPENDINGstamp to the failed stage and its collateral dependents—re-running only the broken subgraph while preserving completed upstream work and keeping the earlierFAILEDstamp in history.
| Exit Code | Meaning | Typical Next Step |
|---|---|---|
0 |
Workflow completed (or was already completed). | Done. |
1 |
Stage failure, compile error, or a payload that fails the gate schema. | Inspect lightflow status, fix the cause, and run lightflow resume. |
2 |
Suspended at an operator_action gate awaiting human/agent input. |
Run lightflow resume --stage=<name> --resolution=APPROVE --payload='{...}'. |
3 |
No saved state for the log ID (status, resume). |
Check the log ID (lightflow list), or start. |
4 |
Another process holds the run's lock (start, resume, cleanup, status). |
Wait, then check status again. |
5 |
Waiting: a polling_policy stage yielded (timeout / max attempts) before its condition was met. Nothing is broken; downstream stages are not touched. |
Once the external condition holds, run lightflow resume. |
lightflow status exits with the code of the run it inspects (0 completed,
1 failed, rejected, or interrupted, 2 paused, 5 waiting), so a script or agent can poll
it and branch exactly as it would on start or resume. It prints one line per
stage plus any paused gate's Instructions, Payload Schema, and resume command;
--verbose adds the Passport payload, full stamp log, and Mermaid diagram.
This repository ships with plugin.json, mcp_config.json, and two progressive-disclosure skills (skills/run_lightflows/SKILL.md and skills/create_lightflows/SKILL.md):
# Option A: Mount the entire repository as a single plugin (skills + MCP server)
ln -s "$(pwd)" ~/.gemini/config/plugins/lightflow
# Option B: Mount only the on-demand skills
ln -s "$(pwd)/skills/run_lightflows" ~/.gemini/config/skills/run_lightflows
ln -s "$(pwd)/skills/create_lightflows" ~/.gemini/config/skills/create_lightflowsAdd lightflow-mcp to your claude_desktop_config.json or .mcp.json:
{
"mcpServers": {
"lightflow": {
"command": "lightflow-mcp",
"args": []
}
}
}Copy AGENTS.md or CLAUDE.md into your
repository root (or install
skills/run_lightflows/SKILL.md and
skills/create_lightflows/SKILL.md) to
instruct your agent on executing, resuming, and authoring Lightflow workflows.
The create_lightflow scaffolding meta-workflow those files reference lives in
this repository under examples/create_lightflow/; run it from a clone of
github.com/google/lightflow, or pass the absolute path to that directory in
your clone (--lightflow=/path/to/lightflow/examples/create_lightflow).
Licensed under the Apache 2.0 License.
This is not an officially supported Google product. Eligibility for the Google Open Source Software Vulnerability Rewards Program is determined by the Google Open Source Software Vulnerability Reward Program Rules.