Workflow Graph Engine

August 29, 2026 · View on GitHub

The workflow graph engine defines all factory modes as directed graphs of typed nodes. One source of truth, two execution formats: headless automation via WorkflowExecutor and interactive CEO sessions via auto-generated SKILL.md files.

How it works

definitions.py          (8 Python functions, each returning a Workflow)

       ├──► WorkflowExecutor    (headless: walks the DAG deterministically)
       │       factory workflow run improve --project /path

       └──► skill_export.py     (interactive: converts graph → SKILL.md)
               skills/workflow-improve/SKILL.md
               └── CEO reads this at runtime as its mode-specific playbook

In interactive mode, factory ceo launches a Claude Code session with ceo.md as the system prompt. The CEO detects project state, then reads the appropriate SKILL.md into context and follows it step by step. The SKILL.md files are prose translations of the same graph the executor walks — so both paths execute the same pipeline.

Node types

Every workflow is a graph of 6 node types connected by edges:

NodeClassPurposeExample
AgentAgentNodeSpawn a Claude Code specialist agentResearcher, Builder, QA
FunctionFnNodeRun a shell commandfactory eval {project_path}
GateGateNodeDecision point producing PROCEED / RELOOP / HALTCEO reviewing research quality
ForkForkNodeLaunch multiple targets in parallel3 researchers simultaneously
JoinJoinNodeBarrier — wait for all parallel branchesWait for all researchers
StudyStudyDistinguished FnNode wrapping factory studyLocal codebase analysis

Each node declares reads and writes — the set of files it consumes and produces. The graph validator (validation.py) uses these to verify data flow: every file a node reads must be written by a predecessor. Pre-existing project files (e.g. CLAUDE.md, factory.md) should not be declared as reads since no workflow node produces them.

Edges and verdicts

Edges connect nodes. Unconditional edges always fire. Conditional edges fire only on a specific verdict from a GateNode:

Edge(source="gate_qa", target="gate_precheck", condition=VerdictType.PROCEED)
Edge(source="gate_qa", target="builder",        condition=VerdictType.RELOOP)

Three verdict types:

  • PROCEED — output is satisfactory, continue to the next step
  • RELOOP — output needs improvement, go back to a target node (max 3 iterations)
  • HALT — something is fundamentally wrong, stop the workflow

Workflows

8 workflows are registered in definitions.py:

NameFunctionTriggerPurpose
buildbuild_workflow()no_repo or incompleteBuild a new project from idea/spec
designdesign_workflow()no_repo + interactiveSame as build but with user approval gate at strategy
improveimprove_workflow()has_factoryImprove an existing project through experiments
researchresearch_workflow()has_factory + research_targetResearch-driven optimization with failure analysis
metameta_workflow()has_factory + mode=metaImprove the factory itself + ACE playbook evolution
discoverdiscover_workflow()no_factoryAuto-discover eval dimensions
reviewreview_workflow()evals_pending_reviewVerify eval dimensions and initialize factory config
refinerefine_workflow()has_factory + --refineLightweight pipeline for user-directed refinements

Relationships: W2 (design) = W1 (build) with gate_strategy.evaluator_type = "user". W4 (research) extends W3 (improve) with baseline measurement, failure analyst, surface constraints, and plateau detection.

Creating a new workflow

Here is the discover workflow (simplest — 3 nodes) as an example:

from factory.workflow.primitives import (
    AgentRole, Edge, FnNode, GateNode, VerdictType, Workflow,
)

def discover_workflow() -> Workflow:
    nodes = {}
    edges = []

    # Step 1: Run discovery command
    nodes["discover"] = FnNode(
        id="discover",
        command="factory discover {project_path}",
        writes={".factory/eval_profile.json", "eval/score.py"},
    )

    # Step 2: CEO reviews the result
    nodes["gate_discover"] = GateNode(
        id="gate_discover",
        evaluator_type="agent",
        evaluator_role=AgentRole.CEO,
        gate_prompt="Verify the discovered eval profile makes sense...",
        reads={".factory/eval_profile.json", "eval/score.py"},
    )

    # Step 3: Re-detect project state
    nodes["redetect"] = FnNode(
        id="redetect",
        command="factory detect {project_path}",
        reads={".factory/eval_profile.json"},
    )

    # Wire them: discover → gate → redetect (on PROCEED)
    #                         └→ discover (on RELOOP — retry)
    edges = [
        Edge(source="discover", target="gate_discover"),
        Edge(source="gate_discover", target="redetect", condition=VerdictType.PROCEED),
        Edge(source="gate_discover", target="discover", condition=VerdictType.RELOOP),
    ]

    # Auto-select when project has no factory setup
    def trigger(state, ctx):
        return state == ProjectState.NO_FACTORY

    return Workflow(
        name="discover",
        nodes=nodes,
        edges=edges,
        start_node="discover",
        trigger=trigger,
    )

To register it, add the function to register_all() in definitions.py:

def register_all() -> dict[str, Workflow]:
    return {
        ...
        "discover": discover_workflow(),
    }

Common patterns

QA iteration loop (builder → QA → gate with RELOOP back to builder, max 3 iterations):

nodes["builder"] = AgentNode(id="builder", role=AgentRole.BUILDER, ...)
nodes["gate_build"] = GateNode(id="gate_build", ...)
nodes["qa"] = AgentNode(id="qa", role=AgentRole.QA, ...)
nodes["gate_qa"] = GateNode(id="gate_qa", ...)
nodes["gate_precheck"] = GateNode(id="gate_precheck", ...)

edges = [
    Edge(source="builder", target="gate_build"),
    Edge(source="gate_build", target="qa", condition=VerdictType.PROCEED),
    Edge(source="qa", target="gate_qa"),
    Edge(source="gate_qa", target="gate_precheck", condition=VerdictType.PROCEED),
    Edge(source="gate_qa", target="builder", condition=VerdictType.RELOOP),  # retry
]

Parallel research (fork 3 researchers, join, then gate):

nodes["fork_research"] = ForkNode(
    id="fork_research",
    targets=["researcher_a", "researcher_b", "researcher_c"],
)
nodes["join_research"] = JoinNode(
    id="join_research",
    sources=["researcher_a", "researcher_b", "researcher_c"],
)

Non-blocking archivist (fire-and-forget):

nodes["archivist"] = AgentNode(
    id="archivist", role=AgentRole.ARCHIVIST,
    model="haiku", blocking=False,
)

Validation

The graph validator (validation.py) checks:

  • Start node exists in the node set
  • All edge sources and targets reference existing nodes
  • All nodes are reachable from the start node
  • Cycles only pass through GateNodes with RELOOP edges
  • Fork targets match their ForkNode's target list
  • Join sources match their JoinNode's source list
  • Every file a node reads is written by a predecessor (data flow integrity)

Run validation:

factory workflow validate              # All 8 workflows
python -c "from factory.workflow.definitions import register_all
for name, wf in register_all().items():
    issues = wf.validate_graph()
    print(f'{name}: {\"CLEAN\" if not issues else issues}')"

CLI commands

# Run a workflow (headless, deterministic graph execution)
factory workflow run improve --project /path/to/project
factory workflow run build --project /path/to/project --dry-run

# List all registered workflows
factory workflow list

# Show a workflow's structure (nodes, edges, triggers)
factory workflow show improve

# Validate all workflow graphs
factory workflow validate

# Regenerate SKILL.md files from graph definitions
factory workflow export-skills

Launching the factory

Interactive mode (CEO + skills)

# Improve an existing project
factory ceo /path/to/project

# Build from an idea — brainstorm first
factory ceo "a weather CLI in Rust" --mode design

# Build directly (clear spec)
factory ceo "a weather CLI in Rust"

# Focus on one thing
factory ceo /path/to/project --focus "add auth"
factory ceo /path/to/project --focus 42  # GitHub issue number

# Research-driven optimization
factory ceo "SWE-bench solver" --mode research

# Self-improve the factory
factory ceo /path/to/factory --mode meta

# Quick refinement
factory ceo /path/to/project --refine "fix the login bug"

What happens under the hood:

  1. cmd_ceo() resolves path, mode, focus directives
  2. Creates a git worktree for isolation
  3. Builds a task string describing what the CEO should do
  4. Resolves the CEO system prompt from factory/agents/prompts/ceo.md
  5. Launches claude (or another runner) with the CEO prompt + task
  6. The CEO detects project state → reads the matching skills/workflow-*/SKILL.md → follows it step by step, spawning specialist agents via factory agent <role>
  7. On exit, the worktree is cleaned up

Headless mode (graph executor)

# Direct graph execution — no CEO agent, the executor walks the DAG
factory workflow run improve --project /path/to/project

# With dry-run (no actual agent spawns or commands)
factory workflow run build --project /path/to/project --dry-run

# Headless CEO (pipe mode — for scripting, cron, tmux)
factory ceo /path/to/project --headless
factory run /path/to/project --loop --interval 1800

Continuous loop

# Heartbeat loop — run improve every 30 minutes
factory run /path/to/project --loop --interval 1800

# In a detached tmux session
factory tmux /path/to/project --loop

Package ecosystem (package.py)

A Package wraps a workflow subgraph behind a typed interface and composes with other Packages:

  • Port — named artifact slot (data plane)
  • StateContract — preconditions/postconditions (control plane)
  • OptKnob — parameter the outer loop can mutate (with optional expandable=True for runtime expansion)
  • MemoryDeclaration — what a package persists and how

Composition operators: Sequential, Parallel, Conditional, Loop. All lower to flat Workflow DAGs via Package.compile().

The compiled Workflow carries three knob-related fields populated by compile():

  • knob_values: dict[str, str | float] — current knob defaults
  • knob_bounds: dict[str, list[str | float]] — allowed values per knob
  • knob_expandable: dict[str, str] — expansion hints for knobs the optimizer can grow

These fields are preserved through to_dict()/from_dict() serialization and _deep_copy_workflow() in the mutation operators.

See docs/design/package-ecosystem.md for the full design doc.

File layout

factory/workflow/
├── __init__.py          # Public API: re-exports all primitives + executor
├── primitives.py        # Pydantic models: Node types, Edge, Verdict, Workflow
├── package.py           # Package ecosystem: Package, Port, StateContract, OptKnob,
│                        #   MemoryDeclaration, Sequential, Parallel, Conditional, Loop
├── definitions.py       # 8 workflow functions returning Workflow objects
├── executor.py          # WorkflowExecutor — async graph walker
├── validation.py        # NetworkX-based graph validator
├── events.py            # Structured event types for .factory/events.jsonl
├── skill_export.py      # Graph → SKILL.md converter
└── cli.py               # CLI subcommands: run, list, show, validate, export-skills

skills/
├── workflow-build/SKILL.md       # Auto-generated from build_workflow()
├── workflow-design/SKILL.md      # Auto-generated from design_workflow()
├── workflow-discover/SKILL.md    # Auto-generated from discover_workflow()
├── workflow-improve/SKILL.md     # Auto-generated from improve_workflow()
├── workflow-meta/SKILL.md        # Auto-generated from meta_workflow()
├── workflow-refine/SKILL.md      # Auto-generated from refine_workflow()
├── workflow-research/SKILL.md    # Auto-generated from research_workflow()
└── workflow-review/SKILL.md      # Auto-generated from review_workflow()

Agent pool

The default agent pool maps roles to models:

RoleModelPurpose
researchersonnetWeb research + local analysis
strategistopusHypothesis generation
builderopusCode implementation
qaopusHealth check + code review + adversarial QA
failure_analystopusResearch mode failure classification
ceoopusOrchestration + gate evaluation
archivisthaikuFast, cheap summarization
refineropusRefinement scoping

Configured in DEFAULT_AGENT_POOL in primitives.py. Override per-node with AgentNode(model="sonnet").