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.generatetool.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:
textobjectarraychoicejson
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 field | Rule |
|---|---|
model | Required. A model slot in the workflow's allowlist (model.missing, model.disallowed). |
prompt | Optional string. The step's instruction. {{ ... }} expressions are resolved at run time. |
system | Optional 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.systemafter a blank line. The default runtime gives the worker no system prompt of its own, so without skills the system prompt iswith.systemalone. - One user message:
with.prompt, a blank line,Input:, and the resolved step input. The step input isinput, elsewith.args, else the workflow input (or the current item inside aparallelbody); a non-string value is sent as JSON. Withoutwith.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.casesis an ordered array. The firstwhenexpression that evaluates truthy wins.with.defaultis required. It is the fallback target when nowhenexpression matches.- Every
tovalue (incasesanddefault) 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
decisionstep cannot route outside its enclosing scope. In a parallel branch, everytomust name a step in that branch or"end". - The
StepCompletedpayload for a decision step is{ "chosen": "<targetId>" }. Decision steps never contribute output to fan-in; only their routing is recorded. - A
decisionstep does not accept anoutputfield and does not usesensitiveormaxVisitsmeaningfully (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.runstep validates and compiles, but the defaultworkflowHarnesschecks the step's sandbox policy and then throws (cannot safely execute code.run in the default workflow harness). To run one, supply aworker.harnessthat implementsexecute_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 field | Rule |
|---|---|
entrypoint | A safe relative path ending in .ts that is a key of files. |
files | At 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. |
sandbox | Required. 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."
}
}
}| Field | Type | Notes |
|---|---|---|
fixer.model | string | Required. A model slot in the workflow's allowlist (fixer.model.missing, fixer.model.disallowed) |
fixer.maxAttempts | number | Required positive integer, with no upper cap; max fix iterations before escalating (fixer.max_attempts_invalid) |
fixer.system | string | Required 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
}
}
}| Field | Type | Notes |
|---|---|---|
repair.mode | "self" | Retries with the step's own model, feeding the validator findings back into the prompt |
repair.maxAttempts | number | Max 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": "…" }
}maxVisitsmust be an integer in[1, 100]. Values outside this range are rejected at validation time (step.max_visits_out_of_range).- Default
1preserves existing DAG semantics. Step paths are unchanged whenmaxVisits === 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 withRunFailedError { 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):
- A cycle is permitted only if it contains at least one
decisionstep. A cycle with nodecisionstep has no exit condition and is rejected (lwir.invalid_cycle). - Every step on the cycle must have
maxVisits ≥ 2. Any step on the cycle withmaxVisits === 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
parallelbody to a step outside it; - a reference whose
needsentry 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:
- If the parallel group declares
fanIn.outputStep: "<stepId>", that step'slastOutputis used. - Otherwise, the most recently committed non-
decisionstep in the branch contributes itslastOutput.
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:
maxBranchesandmaxConcurrencyare required.maxBranchesis an integer from 1 to 100, andmaxConcurrencyis from 1 tomaxBranches. A run whoseitemsresolve to more thanmaxBranchesentries fails.itemKeymust 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
inputorder anditemKeyorder. failureModeis eitherfail_fastorall_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, andpropertyNames.trueandfalseare accepted as schemas. - Annotations:
description,title, andexamplesare accepted and passed to the model unchanged, so Zod's.describe()and.meta({ title, examples })work. - Records:
propertyNamesmakes string-keyed maps work.z.record(z.string(), V),z.record(z.string().min(1), V),z.record(z.enum([...]), V), andz.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 asz.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 asschema.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.
| Code | Description |
|---|---|
step.max_visits_out_of_range | maxVisits is not an integer in [1, 100] |
lwir.invalid_cycle | Cycle 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.disallowed | A tool.call step has no with.tool, or names a tool outside the workflow allowlist |
model.missing, model.disallowed | An ai.generate step has no with.model, or names a slot outside the workflow allowlist |
decision.target_not_in_scope | A 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_sandbox | A 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_missing | An onFailure.fixer breaks the rules in its table |