Little documentation
Guides and APIReference

LWIR

The Little Workflow Intermediate Representation.

Little Workflow: published on npm as little-workflow@0.2.0-alpha.1 (2026-10-06). Sections labeled UNRELEASED describe source-only changes. Release status

LWIR is the canonical JSON runtime format. TypeScript SDK definitions, visual editor output, YAML examples, and planner output all compile into LWIR.

LWIR must be:

  • JSON Schema valid
  • canonicalizable
  • hashable
  • immutable once registered
  • safe to diff
  • safe to replay from logs

Top-Level Shape

{
  "apiVersion": "littleworkflow.dev/v0.1",
  "kind": "Workflow",
  "metadata": {
    "name": "candidate-review",
    "version": "0.1.0",
    "description": "Produce evidence-backed candidate decision support."
  },
  "input": {
    "schema": {
      "type": "object",
      "additionalProperties": true
    }
  },
  "output": {
    "schema": {
      "type": "object",
      "additionalProperties": true
    }
  },
  "permissions": {
    "tools": ["artifact.read", "artifact.write", "model.call"],
    "models": ["gpt-4o-mini", "gpt-4o-mini-tiger"],
    "secrets": [],
    "network": []
  },
  "steps": []
}

permissions.models lists the slot ids (auto-derived from model names) that steps in this LWIR are permitted to reference. The planner emits these ids; the runtime verifies them against the WorkflowVersion lock at step lease.

Step Types

Alpha step types:

  • ai.generate
  • tool.call
  • constrained local code.run
  • bounded parallel
  • decision (routes execution to a target step or terminates the current scope)

Later versions add broader fan-out, reducers, shell.run, code.python, custom step type plugins, and agent-loop style steps.

Output Modes

Every executable step declares an output mode. Downstream steps consume the validated output value, not raw model text.

{
  "output": {
    "mode": "object",
    "schema": {
      "type": "object",
      "required": ["summary", "severity"],
      "properties": {
        "summary": { "type": "string" },
        "severity": { "type": "string", "enum": ["low", "medium", "high"] }
      }
    }
  }
}

Alpha modes are:

  • text
  • object
  • array
  • choice
  • json

ai.generate supports all alpha output modes (text, object, array, choice, json).

ai.generate Step

An ai.generate step makes one model call on a model slot and validates the reply against the step's output contract.

{
  "id": "summarize",
  "uses": "ai.generate",
  "needs": ["credit"],
  "with": {
    "model": "gpt-4o-mini",
    "system": "You are a loan analyst. Answer in two sentences.",
    "prompt": "Summarize the application. Credit score: {{ steps.credit.output.score }}"
  },
  "input": { "applicant": "{{ input.applicant }}" },
  "output": { "mode": "text" }
}
with fieldRule
modelRequired. A model slot in the workflow's allowlist (model.missing, model.disallowed).
promptOptional string. The step's instruction. {{ ... }} expressions are resolved at run time.
systemOptional string. Appended to the worker's system prompt. {{ ... }} expressions are resolved at run time.

with.prompt and with.system use the same expression roots as input (input, steps.<id>.output, item, workflow, step). They always resolve to text: an embedded expression gives a string, and a template-only value that resolves to an object or array is JSON-stringified. See Expressions.

The model sees the step as:

  • System prompt: the worker's system prompt and skill list, then with.system after a blank line. The default runtime gives the worker no system prompt of its own, so without skills the system prompt is with.system alone.
  • One user message: with.prompt, a blank line, Input:, and the resolved step input. The step input is input, else with.args, else the workflow input (or the current item inside a parallel body); a non-string value is sent as JSON. Without with.prompt, the message is the input alone.

An ai.generate step gets no tools. The default worker harness does not offer it the workflow's tools or bash, whatever its output mode: it makes a single model call and never executes a tool the model asks for. To call a tool, add a tool.call step and read its result with {{ steps.<id>.output }}. A custom worker.harness receives the tools in its context and decides what to do with them.

Decision Step

A decision step evaluates an ordered list of expressions and routes execution to the first matching target, or to the default target if none match.

{
  "id": "route",
  "uses": "decision",
  "needs": ["review"],
  "with": {
    "cases": [
      { "when": "{{ steps.review.lastOutput.passed }}", "to": "fan-in" }
    ],
    "default": "worker"
  }
}

Shape rules:

  • with.cases is an ordered array. The first when expression that evaluates truthy wins.
  • with.default is required. It is the fallback target when no when expression matches.
  • Every to value (in cases and default) must reference either a step in the same scope or the sentinel "end". Using "end" terminates the current scope — a parallel branch or the top-level workflow.
  • A decision step cannot route outside its enclosing scope. In a parallel branch, every to must name a step in that branch or "end".
  • The StepCompleted payload for a decision step is { "chosen": "<targetId>" }. Decision steps never contribute output to fan-in; only their routing is recorded.
  • A decision step does not accept an output field and does not use sensitive or maxVisits meaningfully (it is never revisited by a back-edge that points to it — the cycle must have a decision step on the cycle, but the back-edge target is the step that re-executes).

code.run Step

code.run declares pinned TypeScript source files and an entrypoint. A worker harness that executes it passes {input, tools}, where tools is a proxy giving direct access to all globally-registered tools without routing through the model.

Not executed by the default worker harness. A code.run step validates and compiles, but the default workflowHarness checks the step's sandbox policy and then throws (cannot safely execute code.run in the default workflow harness). To run one, supply a worker.harness that implements execute_step. Execution and isolation are then your responsibility. See Security Model.

{
  "id": "filter-top-candidates",
  "uses": "code.run",
  "needs": ["fetch"],
  "with": {
    "entrypoint": "filter.ts",
    "files": {
      "filter.ts": {
        "content": "export async function run({ input, tools }) {\n  const candidates = await tools.fetchCandidates({ jobId: input.jobId });\n  return candidates.filter((c) => c.score > 0.8);\n}",
        "sha256": "sha256:d1b406298a017e69f3a7cee8b94f6c37b7fee907fcc07772a59065ebf615e35d"
      }
    },
    "sandbox": { "network": "deny", "env": "deny", "fs": "deny" }
  },
  "input": { "jobId": "{{ input.jobId }}" },
  "output": {
    "mode": "array",
    "schema": { "type": "array", "items": { "type": "object" } }
  }
}
with fieldRule
entrypointA safe relative path ending in .ts that is a key of files.
filesAt least one entry. Each key is a safe relative path; each value is { content, sha256 }, where sha256 is sha256: plus the lowercase hex SHA-256 of content encoded as UTF-8.
sandboxRequired. network must be "deny" (or false); env and fs, when present, must be "deny" (or false).

By convention the entrypoint exports async function run({ input, tools }) and returns the step output; the worker harness that executes code.run decides how to load and call it. Tool calls from code are event-logged with caller: "code" (distinguishable from model-driven tool calls). The model never sees the raw tool results.

onFailure.fixer

A code.run step may declare a fixer for self-recovery. When the step's source throws or returns output that fails schema validation, the runtime spawns a fixer harness session:

{
  "id": "compute-score",
  "uses": "code.run",
  "with": {
    "entrypoint": "score.ts",
    "files": {
      "score.ts": {
        "content": "export async function run({ input }) {\n  return { score: input.signals.length / 10 };\n}",
        "sha256": "sha256:1ed396c7cbeba5c323250026707a76c1e1ad3f62572334094e5167c357c6de7c"
      }
    },
    "sandbox": { "network": "deny", "env": "deny", "fs": "deny" }
  },
  "output": {
    "mode": "object",
    "schema": { "type": "object", "required": ["score"], "properties": { "score": { "type": "number" } } }
  },
  "onFailure": {
    "fixer": {
      "model": "gpt-4o-mini",
      "maxAttempts": 3,
      "system": "Fix the code so it returns output matching the schema. Test in scratch before declaring done."
    }
  }
}
FieldTypeNotes
fixer.modelstringRequired. A model slot in the workflow's allowlist (fixer.model.missing, fixer.model.disallowed)
fixer.maxAttemptsnumberRequired positive integer, with no upper cap; max fix iterations before escalating (fixer.max_attempts_invalid)
fixer.systemstringRequired non-empty system prompt for the fixer harness session (fixer.system_missing)

The fixer's fix applies to the current run only (LWIR is immutable). Persistent fixes should be written by the fixer to /mnt/memory/workflow/ for future planners to incorporate.

onFailure.repair

Where onFailure.fixer recovers a failed code.run step, onFailure.repair is the output-repair policy for an ai.generate step whose output fails schema validation. The runtime (maybeRepairAiStep) retries the generation to coax a schema-valid result:

{
  "id": "classify",
  "uses": "ai.generate",
  "with": { "model": "gpt-4o-mini", "prompt": "..." },
  "output": {
    "mode": "object",
    "schema": { "type": "object", "required": ["label"], "properties": { "label": { "type": "string" } } }
  },
  "onFailure": {
    "repair": {
      "mode": "self",
      "maxAttempts": 3
    }
  }
}
FieldTypeNotes
repair.mode"self"Retries with the step's own model, feeding the validator findings back into the prompt
repair.maxAttemptsnumberMax repair iterations

mode: "escalate" (repair on a stronger model) and repair.model (the slot that pass would run on) are reserved for a later phase and not implemented. Rather than accept them and repair nothing, validateLwir rejects both with schema.unimplemented_field. To repair on a different model today, run the step on that model.

Step Metadata

maxVisits

Every step accepts an optional maxVisits integer. Default is 1 (DAG behavior). Setting maxVisits > 1 permits that step to execute more than once in a single run via a back-edge from a decision step.

{
  "id": "worker",
  "uses": "ai.generate",
  "maxVisits": 5,
  "with": { "prompt": "…" }
}
  • maxVisits must be an integer in [1, 100]. Values outside this range are rejected at validation time (step.max_visits_out_of_range).
  • Default 1 preserves existing DAG semantics. Step paths are unchanged when maxVisits === 1.
  • When maxVisits > 1, step paths gain a .visit[N] suffix (0-indexed): review-cands[cand_123].worker.visit[0], …visit[1], etc. Pure DAG steps are unaffected.
  • If a step is about to start its maxVisits + 1-th execution, the run fails with RunFailedError { causeCode: "max_visits_exceeded", failedStepPath }. The failed run is preserved in Local World for inspection.

sensitive

Steps accept an optional sensitive: true field on their metadata. The field is parsed and validated by the LWIR validator but not yet honored by the alpha runtime. It is reserved so a future release can suppress inline output in the event log without a breaking LWIR upgrade.

{
  "id": "fetch-secret",
  "uses": "tool.call",
  "sensitive": true,
  "with": { "tool": "fetchCredential" }
}

Back-Edges and Cycle Rules

A back-edge is a decision step's to that names a step which has already executed in the current branch. Back-edges enable in-workflow loops.

Validator rules (Tarjan SCC over the union of needs edges and decision-step transition edges):

  1. A cycle is permitted only if it contains at least one decision step. A cycle with no decision step has no exit condition and is rejected (lwir.invalid_cycle).
  2. Every step on the cycle must have maxVisits ≥ 2. Any step on the cycle with maxVisits === 1 (the default) is rejected (lwir.invalid_cycle).

A cycle that satisfies both rules is valid. Loops are bounded by maxVisits at runtime.

Expressions — Multi-Visit Accessors

When a step has maxVisits > 1, two additional accessors are available:

  • {{ steps.X.lastOutput }} — output of the most recent committed visit of step X.
  • {{ steps.X.allVisits }} — ordered array of all committed visit outputs for step X.

{{ steps.X.output }} retains its existing meaning for single-visit steps. Referencing {{ steps.X.output }} on a step with maxVisits > 1 is a validation error (lwir.output_ambiguous_for_multi_visit). The planner must use lastOutput or allVisits explicitly for multi-visit steps.

References via lastOutput or allVisits are exempt from the needs-declaration requirement when the referenced step can reach the referencing step through the execution-flow graph (needs-edges plus decision-transition-edges). This covers the back-edge-in-a-cycle pattern, where an earlier step in a loop reads the output of a later step without declaring a needs entry for it.

Needs A Planner Left Out

A step that reads {{ steps.X.output }} must list X in needs. Planners often write the reference and forget the needs entry. Before the compiler validates a planner's draft, it adds the entry for every step the draft reads from the same step list, including steps inside a parallel body that read each other. The plan then validates on the first try instead of costing a repair call, and the stored WorkflowVersion carries the added entries. A draft that already validates is unchanged.

These are not inferred, so they stay validation errors that go back to the planner:

  • a reference to a step that does not exist (step.missing_dependency);
  • a reference from a parallel body to a step outside it;
  • a reference whose needs entry would close a cycle.

A fixed plan (defineWorkflow({ plan })) runs as written, so it must list its own needs.

Bounded Parallel Nodes — Updates

fanIn.outputStep

The fanIn object on a parallel step's with accepts an optional outputStep field:

{
  "fanIn": { "order": "input", "output": "array", "outputStep": "worker" }
}

When outputStep is set, the named step's lastOutput is used as the branch's contribution to fan-in. The named step must exist in the branch body and must not be a decision step.

When outputStep is omitted, the branch's final output is the lastOutput of the most recently committed non-decision step in the branch. This default preserves existing behavior for single-path DAG branches.

Branch Final Output Rule

When a parallel branch terminates (via to: "end" from a decision step or by exhausting its DAG), its contribution to fan-in is determined as follows:

  1. If the parallel group declares fanIn.outputStep: "<stepId>", that step's lastOutput is used.
  2. Otherwise, the most recently committed non-decision step in the branch contributes its lastOutput.

decision steps are always excluded from fan-in output.

Worked Example — Candidate Review With Loop

The following LWIR excerpt shows a parallel group where each branch runs a worker → review → decision loop. The branch exits when the review passes or when maxVisits is exhausted.

{
  "id": "review-cands",
  "uses": "parallel",
  "with": {
    "items": "{{ input.candidates }}",
    "itemKey": "{{ item.id }}",
    "maxBranches": 25,
    "maxConcurrency": 5,
    "failureMode": "all_settled",
    "fanIn": { "order": "input", "output": "array" }
  },
  "steps": [
    {
      "id": "worker",
      "uses": "ai.generate",
      "maxVisits": 5,
      "with": {
        "model": "gpt-4o-mini",
        "prompt": "Process {{ item }}. Prior feedback: {{ steps.review.lastOutput.feedback }}"
      }
    },
    {
      "id": "review",
      "uses": "ai.generate",
      "needs": ["worker"],
      "maxVisits": 5,
      "with": {
        "model": "gpt-4o-mini",
        "prompt": "Review worker output. History: {{ steps.worker.allVisits }}"
      }
    },
    {
      "id": "route",
      "uses": "decision",
      "needs": ["review"],
      "with": {
        "cases": [{ "when": "{{ steps.review.lastOutput.passed }}", "to": "end" }],
        "default": "worker"
      }
    }
  ]
}

The route decision step routes back to worker (back-edge) when the review does not pass, or terminates the branch with to: "end" when it does. Both worker and review carry maxVisits: 5, satisfying the cycle rule. The branch's fan-in output is review's lastOutput (the most recently committed non-decision step).

Model slot ids ("gpt-4o-mini") are auto-derived from the model name passed to model() at authoring time. The planner emits these ids; the runtime resolves them to the registered ModelSlot.

Result Envelopes

Step outputs are committed as durable result envelopes:

{
  "output": {
    "summary": "Customer reports intermittent billing export failures.",
    "severity": "high"
  },
  "outputRef": "artifact://art_step_output",
  "artifacts": ["artifact://art_prompt", "artifact://art_response"],
  "metadata": {
    "uses": "ai.generate",
    "outputMode": "object",
    "schemaHash": "sha256:..."
  }
}

The result envelope carries no usage or cost. Per-step tokens and dollars are derived at materialization time from the step's harness.model.responded events — each stamped with the owning stepPath and the { provider, modelId } to price it against — and surface as MaterializedRunState.steps[stepPath].usage and in little report <run-id>. costUsd there is number | null, where null means the calls had no registry pricing, not that they were free.

Small outputs may live inline in the event log. When the serialized output exceeds 256 KB the inline output field is omitted; only outputRef is written. Steps marked sensitive: true will also suppress inline output in a future release (reserved in alpha). Replay reads from outputRef; the inline value is never load-bearing.

Bounded Parallel Nodes

Alpha supports bounded parallel branches over a known array:

{
  "id": "review-candidates",
  "uses": "parallel",
  "with": {
    "items": "{{ input.candidates }}",
    "itemKey": "{{ item.candidateId }}",
    "maxBranches": 25,
    "maxConcurrency": 5,
    "failureMode": "all_settled",
    "fanIn": {
      "order": "input",
      "output": "array"
    }
  },
  "steps": [
    {
      "id": "score-candidate",
      "uses": "ai.generate",
      "with": {
        "model": "gpt-4o-mini",
        "prompt": "Score {{ item }} against {{ input.jobRequirement }}."
      },
      "output": {
        "mode": "object",
        "schema": {
          "type": "object",
          "required": ["candidateId", "score", "reason"],
          "properties": {
            "candidateId": { "type": "string" },
            "score": { "type": "number" },
            "reason": { "type": "string" }
          }
        }
      }
    }
  ]
}

Rules:

  • maxBranches and maxConcurrency are required. maxBranches is an integer from 1 to 100, and maxConcurrency is from 1 to maxBranches. A run whose items resolve to more than maxBranches entries fails.
  • itemKey must be deterministic and unique in the group.
  • branch step paths include the item key, such as review-candidates[cand_123].score-candidate.
  • fan-in order is deterministic; alpha supports input order and itemKey order.
  • failureMode is either fail_fast or all_settled.

This is for small branch sets such as processing candidate records. Sharded fan-out, tree reducers, distributed workers, and high-volume batching arrive later.

Expressions

LWIR uses a deterministic expression language:

{{ input.jobDescription }}
{{ steps.extract.output.name }}
{{ item.uri }}
{{ sha256(item.uri) }}
{{ coalesce(steps.profile.output.name, "unknown") }}

Allowed expressions are pure and bounded. Arbitrary JavaScript, filesystem access, network access, secret access, and dynamic imports are disallowed.

Schema Subset

LWIR supports a deliberate JSON Schema subset. It applies to workflow input/output schemas, step output schemas, and the tool input schema a workflow exposes to a Little Harness agent.

  • Accepted keywords: type, properties, required, additionalProperties, items, enum, const, anyOf, oneOf, minLength, maxLength, minimum, maximum, minItems, maxItems, and propertyNames. true and false are accepted as schemas.
  • Annotations: description, title, and examples are accepted and passed to the model unchanged, so Zod's .describe() and .meta({ title, examples }) work.
  • Records: propertyNames makes string-keyed maps work. z.record(z.string(), V), z.record(z.string().min(1), V), z.record(z.enum([...]), V), and z.partialRecord(z.enum([...]), V) are accepted, and keys are checked at run time on workflow input and on step and final output. A record keyed by a non-string schema, such as z.record(z.number(), V), is rejected because JSON object keys are always strings.
  • Rejected: pattern (.regex()), format (.email(), .url(), …), exclusiveMinimum/exclusiveMaximum (.positive(), .gt(), .lt()), default (.default()), deprecated, readOnly, writeOnly, patternProperties, if/then/else, and $ref. Each is reported as schema.invalid. The alpha runtime would not enforce them, so accepting them would silently drop the constraint.

Zod transforms, functions, Date, bigint, classes, Map/Set, and custom refinements have no JSON Schema form and cannot be used. Validate finer constraints inside a tool. See LWIR Internals → JSON Schema Subset for the validator's details.

LWIR also rejects fields that are declared but not implemented, rather than accepting and ignoring them. These are non-empty permissions.secrets (no secret broker), non-empty permissions.network (no egress allowlist), onFailure.repair.mode: "escalate" and onFailure.repair.model (no escalated repair), and a step-level cache (no step cache). Each is reported as schema.unimplemented_field.

Validators

These are the validator finding codes for the rules on this page. Other findings (for example schema.*, step.*, parallel.* and output.*) cover the rest of the format.

CodeDescription
step.max_visits_out_of_rangemaxVisits is not an integer in [1, 100]
lwir.invalid_cycleCycle with no decision step, or a cycle step without maxVisits ≥ 2
lwir.output_ambiguous_for_multi_visit{{ steps.X.output }} used on a step with maxVisits > 1
tool.missing, tool.disallowedA tool.call step has no with.tool, or names a tool outside the workflow allowlist
model.missing, model.disallowedAn ai.generate step has no with.model, or names a slot outside the workflow allowlist
decision.target_not_in_scopeA decision to target is outside the decision step's scope
code.missing_entrypoint, code.invalid_entrypoint, code.missing_files, code.invalid_file_path, code.invalid_file, code.invalid_file_hash, code.missing_sandbox, code.invalid_sandboxA code.run step's with breaks the rules in the code.run section
fixer.model.missing, fixer.model.disallowed, fixer.max_attempts_invalid, fixer.system_missingAn onFailure.fixer breaks the rules in its table

On this page