Workflow Schema
This is the authoritative reference for a workflow asset's exact frontmatter
and body syntax — every key, its shape, and the validation rules enforced by
the published JSON Schema (schemas/akm-workflow.json) and
akm lint --type workflows. Other pages link here instead of reproducing this
detail.
- For a task-oriented walkthrough of writing your first workflow, see the Author's Guide.
- For how a frozen plan actually executes — persistence, dispatch, resume — see Architecture: The Workflow Engine.
- For operating a run day to day (
run,status,resume,abandon), see Running Workflows.
Source formats and shared IR
Peer workflow sources include Markdown .md and GitHub-shaped YAML .yml.
.yaml is not supported or recognized as a workflow source. Both adapters
compile into the same strict source IR version 1 (sourceIrVersion: 1) before
target resolution and durable freezing.
The shipped schemas/akm-workflow.json frontmatter schema applies to and
validates the Markdown source only. Markdown is not the sole or only workflow
source; the GitHub-shaped YAML adapter has its own bounded parser and shares
the source-IR decoder and semantic authorities.
Markdown source
A Markdown workflow is an ordinary AKM asset — the same envelope as every other Markdown type, OKF-conformant frontmatter plus a body — whose frontmatter carries the orchestration graph (params, and how each step dispatches, fans out, routes, and gates). Its body carries each step's instructions and gate rubric under plain headings, joined to the frontmatter by step id. The remainder of this page's frontmatter/body sections document that Markdown authoring format.
GitHub-shaped YAML subset
A complete valid on plus jobs document is one workflow asset.
It never creates a duplicate or second task asset. The root vocabulary is exactly
name, on, and jobs:
name: Local checks
on:
schedule:
- cron: "0 6 * * *"
workflow_dispatch: {}
jobs:
checks:
runs-on: [self-hosted]
steps:
- id: lint
run: bun run lint
- id: review
uses: akm/command
with:
ref: commands/review
AKM's public position on this format: AKM YAML uses a familiar GitHub-step-shaped syntax but is an AKM workflow format, executed by AKM's native engine. It is not Marketplace-action-compatible, does not evaluate GitHub expressions or contexts, has no hosted runner images or service containers, and does not execute multiple jobs.
The accepted 0.9.2 subset is deliberately closed:
onaccepts five-fieldscheduleentries and an empty or nullworkflow_dispatch; workflow_dispatch inputs are unsupported.- Service events are rejected. A rejected service event creates no watcher and no polling daemon.
jobs:must contain exactly one job. A document with zero, two, or more jobs fails at the adapter with reasonmulti-job-unsupported, surfaced asUsageErrorcodeCOMPOSITION_INVALIDwhen the workflow is frozen (akm workflow runorakm workflow plan). Split a multi-job document into separate single-job workflows and compose them with a child-workflow step (uses: workflows/<ref>) instead. A job'sneeds:must be empty — a non-emptyneeds:fails for the same reason, since a single job has nothing to depend on.- Each job requires exactly
runs-on: [self-hosted].name,needs, andstepsare the remaining job fields. - Each step requires
idand exactly oneusesorrun; optional fields arename,with,env,shell, and containedworking-directory. - A
runaccepts only token-safe local command tokens. Shell expansion and operators are unsupported and rejected, even when a host shell is named. usesis classified as a canonical asset ref (commands/,scripts/,tasks/,workflows/), plus theakm/commandbuiltin.akm/command, command, script, task, and child-workflow composition are local targets — see Child workflows foruses: workflows/<ref>. Local actions and Docker actions are unsupported and rejected (including./anddocker://); a GitHub Action locator (owner/repo[/path]@ref, e.g.actions/checkout@v4) and every other unrecognized shape fail the same way —unsupported-uses-target— since AKM never acquires or executes a remote action. AKM does not recognize the locator grammar as a distinct case; it is simply not one of the four canonical asset-ref families or theakm/commandbuiltin.with:on a task-composed step (uses: tasks/<ref>) binds the target task source's declaredinputs:— see Typed inputs and output. Each value is either a literal (validated against the input's declared schema at freeze) or a reference{from: "steps.<id>.output(.<segment>)*"}, resolved just before the unit dispatches and re-validated against the same schema then. The reference grammar also accepts{from: "params.<name>"}, naming a declared param of the composing workflow itself — but a composing step is only authorable in a GitHub-shaped document (this section), whose root keys are exactlyname,on, andjobswithworkflow_dispatchinputs rejected, so it can never declareparams:of its own. In practice that reference form therefore always fails freeze here, with "does not name a declared workflow param; declared params: (none)". An unknownwith:key, a missing required input with no default, or a reference naming a step that doesn't exist earlier in the job all fail at freeze withUsageErrorcodeINPUT_BINDING_INVALID, before the plan is ever published. If the target task declares noinputs:at all (aversion: 4task with noinputs:key) — or the step targetsuses: commands/<ref>/uses: scripts/<ref>, which are never binding surfaces — any authoredwith:, including an empty mapping (with: {}), is rejected at freeze withUsageErrorcodeCOMPOSITION_INVALID, exit 2. Omittingwith:entirely always freezes normally, regardless of target.with:onuses: akm/commandis unaffected by any of this and is still required to supply the builtin action's arguments, as in the example above.with:on a child-workflow target (direct or task-wrapped) binds the child's declaredparams:instead ofinputs:— see Child workflows.- GitHub expressions and contexts are unsupported and rejected anywhere in the parsed tree.
Child workflows
A step can compose another workflow, two ways. Composition is authored only
through the GitHub-shaped jobs.<id>.steps[].uses surface — the composing
(parent) document must be GitHub-shaped YAML, since the Markdown-frontmatter
step schema has no uses: key at all (see Source formats and shared
IR). The child workflow being composed
may itself be authored in either format, Markdown or GitHub-shaped:
-
Direct —
uses: workflows/<ref>:- id: dispatch uses: workflows/release-checklist with: channel: stable -
Task-wrapped —
uses: tasks/<ref>where<ref>names a task source v4 document whose own target is a workflow. The task's own effectiveinputs:(its declared defaults plus whatever the task's own callers bound) supply the child's params; awith:authored on the workflow step itself binds on top of that, exactly as it would for a direct step.
Both forms bind against the child workflow's own declared params:
frontmatter key — not a task's inputs: contract, since a workflow has
no inputs:. with: follows the same grammar as everywhere else in this
document: each value is a literal (validated against the param's declared
type at freeze) or a {from: "steps.<id>.output(.<segment>)*"} reference,
resolved just before the unit dispatches. (The grammar also accepts
{from: "params.<name>"}, naming a declared param of the composing
workflow — but that form is unreachable here for the same reason noted
above: the composing document is necessarily GitHub-shaped, so it never
declares params: of its own.) An unknown key or an invalid reference
fails at freeze with UsageError code INPUT_BINDING_INVALID, exactly like
a task-composed step's with:.
Frozen before publication
Composing a child workflow is not a runtime call — it is a freeze-time
resolution. When the parent workflow's plan is frozen, AKM loads the child's
source, compiles it, validates it, and freezes the child's own complete plan
before the parent run is published. The result is embedded whole inside
the parent step's frozen target (kind: "child-workflow"); nothing about the
child is re-read at dispatch time. Concretely:
- Editing the child's source after the parent run has started has no effect on that run — the parent already carries its own frozen copy of the child's plan.
- Editing the child's source in the narrow window between the parent's freeze and its publication fails the whole parent publication atomically, with no run row written — the same source-race protection that already covers the parent's own command/script/task sources extends to every transitive child source file.
- The child's own source files (its workflow document plus every command/script/task it in turn resolves) become part of the parent run's guarded source read set, exactly like any other source the parent workflow depends on.
See Architecture: The Workflow Engine
for how an embedded child plan is decoded (irVersion, planHash, and
contentHash are recorded provenance, not re-verified).
Composition limits
Three bounds are enforced at freeze, before the parent run is published,
each failing with UsageError code COMPOSITION_INVALID (exit 2):
| Limit | Value | Message names |
|---|---|---|
| Composition depth | 8 levels below the root | the limit and the ref path, e.g. Workflow step <id> cannot compose <ref>: workflow composition is limited to 8 levels. Path: <a -> b -> …>. |
| Cycle detection | a workflow (direct or task-wrapped) reaching itself through any chain | the cycle path, e.g. Workflow step <id> cannot compose <ref>: that would create a composition cycle. Path: <a -> tasks/w -> b -> a>. |
| Aggregate embedded plan bytes | 1 MiB total, summed across every embedded descendant of one root freeze | the cap and the running total, e.g. Workflow step <id> cannot compose <ref>: the embedded child plans would total <N> bytes, over the <cap>-byte limit for one workflow run. |
The same workflow reached twice through disjoint branches (a diamond, not a
cycle) is not a violation — each occurrence embeds its own independent copy;
deduplicating identical embedded plans is not implemented. A step whose
uses: workflows/<ref> (or task-wrapped equivalent) does not resolve to a
real asset fails with the ordinary asset-resolution error, unchanged by any
of this.
Child execution
Running a step whose own target is kind: "child-workflow" publishes (or
finds, if one already exists — see "Identity and retries" below) a child
run and drives it to completion, or as far as it gets, before the parent
step is finalized. The drive happens inline, in the parent's own
process: it is the same engine akm workflow run uses on the child's
frozen plan, not a separately scheduled job. Consequently, whatever aborts
the parent's own dispatch — Ctrl-C, a --timeout, or a budget ceiling —
also aborts the child drive; both runs are left resumable, never partially
torn down.
The child's final status maps onto the composing step and the parent run:
| Child status | Composing step | Parent run |
|---|---|---|
completed |
completes; its output is the child's exported result — its declared outputs: (see What a step's output is), or {runId, status} when the child declares none |
continues |
failed |
failed |
failed |
blocked |
blocked |
blocked |
| aborted mid-drive (parent cancelled/timed out) | left unfinished, not finalized | active and resumable |
the child could not be published (its params do not satisfy the child's declared params:) |
failed |
failed |
| another process already holds the child's run lock | failed |
failed |
Blocked-child recovery. A blocked child blocks its composing step —
akm does not resume a child for you, because a gate is a gate for a
child workflow too. The step's notes name the exact three-command
sequence: resume the child first, then resume and re-run the
parent — re-driving the parent is what advances it, because that is
what re-enters the composing step and drives the now-resumed child:
akm workflow resume <childRunId>
akm workflow resume <parentRunId>
akm workflow run <parentRunId>
Nested blocks (composition depth 2+). The three-command sequence above
clears a block exactly one level deep. It does not generalize to a
grandchild block (root composes child, child composes grandchild, grandchild
blocks): re-driving the root does not cascade down into re-driving the
still-blocked grandchild, because a composing step never re-drives a child
whose own status is already blocked — re-running the root just re-observes
the child's own block and re-blocks the root the same way, without the
grandchild ever being reached. Resume every blocked run in the chain,
deepest first, then re-run only the root:
akm workflow resume <grandchildRunId>
akm workflow resume <childRunId>
akm workflow resume <rootRunId>
akm workflow run <rootRunId>
The status tree's own resume/then commands on a deeply nested node still
name only that node and the root, never an intermediate ancestor — read the
tree and resume every blocked row before re-running the root.
Identity and retries. A child run's identity is keyed by the parent run,
the parent unit, and that unit's input hash — publishing is idempotent, so
re-driving the composing step (an explicit akm workflow resume +
akm workflow run, or a retry: policy on the step) finds and continues
the same child run rather than starting a new one. Only a change to the
composing step's own inputs (params, upstream step outputs it reads, or gate
feedback from a rejected verification loop) produces a different child.
Visibility. akm workflow status on a run that composes children
renders a children: tree — every descendant run's ref, status, and, for a
blocked child, its resume command — recursively to the same 8-level
composition-depth bound described above. Child runs are excluded from akm workflow list by default; pass --children to include them. A child run
id always works directly with akm workflow status/resume/abandon/run,
listed or not. See
Running Workflows: Child runs for a
worked example, and
Architecture: The Workflow Engine
for the dispatch seam and why reusing the top-level run engine (rather than a
second executor) is what makes the identity and abort-propagation rules above
hold for free.
Frontmatter keys
Frontmatter is the standard AKM asset envelope (type, description, tags,
when_to_use, xrefs, updated/timestamp, and the OKF v0.2 trust/lifecycle
families) plus the orchestration keys:
params— name →{ type, description }(JSON-Schema-typed, unlike a bare description string).defaults— run-level dispatch defaults (engine,model,llm,timeout,on_error), overridable per unit.llm:tuning reaches an engine of any kind, and a document-levelllm:reaches EVERY step. An LLM engine sends it; an agent engine reports it as anuntranslated-fieldnotice on that step (see Inference on an agent engine). There is no per-step opt-out (llm: {}is a no-op andllm: nullis a parse error), so in a mixed document putllm:on theunit:of each step it is meant for instead of indefaults:.outputs— name →{ from, schema? }, a run-level export projected from a step's own artifact (Markdown-only; see Workflow outputs below).budget— run-lifetime ceilings (max_units,max_tokens; see Budget ceilings below).steps— an ordered list. Each step has anid([A-Za-z_][A-Za-z0-9_-]*— no dots) and at most one ofunit,map, orroute. A step with neither is still a unit step — bare- id: validateis the complete minimal declaration.unit:is the optional dispatch-override bag (exec,engine,model,llm,timeout,retry,on_error,env,isolation; see Exec (shell) units, Failure policy and Worktree isolation).inputs— on aunit/mapstep, the prior-step artifacts this step consumes, as bare reference strings (sub-paths legal:steps.x.output.issues, not juststeps.x.output). This is how a step's attached context sees upstream data, and how replay hashing gets its exact input set — a step re-dispatches only when the slice it actually consumes changes.output— a JSON Schema for the step's promoted artifact.gate— optional validation-loop configuration:max_loopsbounds evaluator-optimizer retries (see Gates and verification). The rubric itself lives in the body's### gatesection. Without non-empty rubric text, the configuration is inert.
No version:/name: keys — identity is the ref, and the frozen plan already
versions execution semantics — and no step titles anywhere: a step is its id,
and the asset's human name is its description and H1 like any other asset
type.
Body structure and rules
Checked by akm lint --type workflows:
- Every level-2 heading must be
## <step-id>for a step declared in frontmatter, exactly — no titles, noStep:/Step ID:lines, no# Workflow:prefix on the H1. (Fenced code blocks are skipped when scanning for headings.) - A
unitormapstep must have a body section — its instructions, or its per-item template for a map step, byte-exact to the next H2 or EOF. Aroutestep may have one (documentation, plus a gate rubric if it is gated). Everything before the first H2 is free preamble — indexed for search, shown inakm show, never dispatched. - Inside a step's section, an optional
### gatesub-heading starts that step's gate rubric, running to the section end — the format's single reserved marker. The judge that evaluates the step receives this whole section byte-exact. An omitted or empty### gatesection needs no verification. A non-empty rubric enables mandatory fail-closed verification; frontmattergate:only tunes its retry bound.
Prose is never templated — see The reference grammar for how a step's instructions refer to run params, upstream artifacts, and a map unit's item.
Minimal example
---
type: workflow
description: Ship a tagged release to production
params:
version: { type: string, description: The semver version string to release }
steps:
- id: validate
- id: build
inputs: [steps.validate.output]
---
# Ship Release
## validate
Check that the `version` parameter follows semver and the tag does not
already exist.
### gate
- `git tag v<version>` does not already exist.
- The version string matches `^\d+\.\d+\.\d+$`.
## build
Run `npm run build && npm test`, using the validation from `validate`,
attached to this unit as input. Fix any failures before proceeding.
Richer example
Fan-out, routing, retries, gates, and a run budget:
---
type: workflow
description: Review changed files and route the outcome
params:
changed_files: { type: array, description: Files to review }
defaults: { engine: reviewer, model: balanced, timeout: 10m, on_error: fail }
budget: { max_units: 40, max_tokens: 200000 }
steps:
- id: discover
output: { type: object, properties: { files: { type: array } }, required: [files] }
- id: review
map:
over: steps.discover.output.files
concurrency: 8
unit:
engine: reviewer
model: deep
timeout: 5m
retry: { max: 1, on: [timeout, llm_rate_limit] }
on_error: continue
isolation: worktree
output: { type: object, properties: { file: { type: string }, verdict: { type: string } }, required: [file, verdict] }
# `output` here describes the REDUCER RESULT, not one unit's result: the
# default `collect` reducer folds per-item unit results into an array.
output: { type: array }
gate: { max_loops: 2 }
- id: aggregate
inputs: [steps.review.output]
output: { type: object, properties: { verdict: { type: string } }, required: [verdict] }
- id: triage
route:
input: steps.aggregate.output.verdict
when: [{ match: pass, step: ship }, { match: fail, step: rework }]
default: manual-triage
- id: ship
- id: rework
- id: manual-triage
---
# Review Changes
## discover
List the files that need review, drawn from the `changed_files` parameter.
### gate
Every file named by `changed_files` is listed in the reported result.
## review
This section is the **map unit template** — the engine attaches each unit's
item (the file to review) and its index as context; instructions refer to
"the file you were given," never a template expression.
Review the file you were given for correctness bugs.
### gate
Every changed file has a verdict of `pass` or `fail`.
## aggregate
Combine the per-file review verdicts — attached to this unit as input via
`inputs: [steps.review.output]` above — into one overall verdict, `pass` or
`fail`.
## triage
Routes on the verdict `aggregate` reported: `pass` proceeds to `ship`, `fail`
proceeds to `rework`, anything else goes to `manual-triage`.
## ship
Ship the change.
## rework
Address the review findings. Confirming the fix is a fresh `akm workflow run`
of this workflow, not a step this run routes back to.
## manual-triage
Summarize the ambiguous verdict for a human to triage.
The reference grammar
Workflow prose is never templated — there is no ${{ … }}/{{ … }}
interpolation anywhere in a workflow's body, and no escape syntax to learn,
because there are no delimiters in prose to escape.
Bare reference strings appear in exactly three frontmatter positions, each an unquoted-style YAML string:
| Position | What it names |
|---|---|
map.over |
The list a map step fans out over. |
route.input |
The value a route step matches on. |
inputs (each entry) |
A prior step's artifact this step consumes. |
Every reference resolves against exactly two roots:
| Reference | Meaning |
|---|---|
params.<name> |
A run parameter, by name. |
steps.<id>.output( .<ident> | [<int>] )* |
A prior step's artifact, addressed by producer step id; the path walks properties (.name) and array indexes ([0]). |
Nothing else parses: no functions, no clock, no randomness, no ambient
lookup. item and item_index are not part of the language — a map
unit's item and its index are never referenced from anywhere in frontmatter
or body. They arrive as attached context instead, the same way as
everything else a unit needs.
Context attachment, not string splicing. Each dispatched unit receives, alongside its byte-exact instructions, structured context:
- every run param (params are run-scoped — see Params are not secret below);
- for a map unit, its item and item index;
- the artifacts named by its step's
inputs:.
Instructions refer to this context in plain language — "clone the repository
named by the repo parameter," "review the file you were given," "using the
intake step's artifact attached to this unit" — never by splicing a value
into the instruction string. This closes the injection class at the root:
data never enters the instruction string, spliced or otherwise.
akm lint --type workflows still checks every bare reference statically —
unknown step, unknown param, bad path — at lint time.
Params are not secret
Run params are copied verbatim into every unit's dispatched instructions and
are stored on the run row and shown by akm workflow status, so params are
declared non-secret: secrets belong in env: refs instead, which carry
by name only through the plan and are resolved from akm's env/secret store
at dispatch (see Reference: Env & Secrets).
As a best-effort guardrail, akm workflow run scans a new run's params for
values that look like credentials — secret-suggesting key names (token,
password, apikey, credential, …) or long, high-entropy strings matching
known token prefixes — and surfaces a warning naming the param path and
recommending an env: ref instead. This is advisory only: it never blocks a
run and never mutates params, and false positives/negatives are expected. The
same heuristic feeds the dispatch redaction set, so a unit result or
diagnostic that echoes such a value is scrubbed before it is journaled.
What a step's output is
steps.<id>.output resolves to the value the step's execution produced:
- a
unitstep → the unit's structured result (when the unit declaresoutput) or its text; - a
mapstep → the collected array of per-item results, in item order (underon_error: continue, a failed item's slot isnull), unless the step's ownoutputschema describes a reduced, single-value shape instead; - an exec unit → its stdout (trailing newlines stripped),
or the JSON value stdout parsed to when the unit declares an
outputschema.
An empty successful free-text output is treated as no output. When a
schemaless unit (one that declares no output schema) succeeds but returns
the empty string, akm normalizes it to absent: nothing is journaled for its
result, and its contribution to the step artifact is null — a null slot
in a collected array, or output = null for a solo step. This absence is
deliberate, so a live run and a resumed run promote the identical artifact.
The practical consequence: a downstream step that declares an empty upstream
result in its inputs: gets nothing meaningful attached for it — akm
surfaces this loudly rather than silently attaching an empty string. A unit
that declares an output schema is unaffected — an empty response is not
valid JSON, so it fails as a parse error and can never satisfy a schema as a
silent null.
When a step declares no output: schema
A step-level output: schema is optional, and omitting it is a supported
mode, not a deficiency. The step artifact is then exactly the value described
above — the unit's result for a solo step, the collected array for a map
step — carried untyped and never validated.
The shape is stable either way: it does not depend on whether a schema is
present, and it does not vary between a live run and a resume. steps.<id>.output
resolves the same in both cases, so downstream references work with or without
a schema.
What a schema adds is enforcement, not shape. With one, the promoted
artifact is checked against it and the step fails on mismatch; without one, a
downstream reference to a field the artifact happens not to carry surfaces at
resolution time instead (steps.X.output is not an object — cannot resolve property "y"). Declare output: when you want that mismatch caught at the
producing step rather than at the consuming one.
akm does not warn about a missing step output: schema. It briefly did, and
the advisory was removed: it fired on essentially every step, could not be
enforced (advisories never reach --fail-on-flagged), and flagged a state
that is legitimate.
Workflow outputs
A workflow can declare a run-level export: the values a completed run
promotes, projected from its steps' own artifacts. This is a Markdown
frontmatter key, outputs::
outputs:
report:
from: steps.summarize.output
changed_count:
from: steps.collect.output.total
schema: { type: integer, minimum: 0 }
Each entry is a mapping of exactly from (required) and schema
(optional):
fromis asteps.<id>.output(.<segment>)*reference into a step's own artifact — the same reference grammarwith:/inputs:use elsewhere in this document — naming a declared step.from: params.<name>is rejected: a run's exports come from what its steps produced, not from its input params verbatim.schema, when present, is validated against the same enforced JSON Schema subset as a step's ownoutput:schema, and is bound by the same limit: at most 256 KiB.- Output names follow the same pattern as params,
^[A-Za-z_][A-Za-z0-9_]*$, and a workflow may declare at most 64.
This is a Markdown-only key. GitHub-shaped YAML's root is a closed set —
name, on, jobs — with no extension surface, the same reason a
GitHub-shaped workflow cannot declare params: either. Composition itself
is unaffected: the composing (parent) document must still be GitHub-shaped
(only jobs.<id>.steps[].uses composes — see Child
workflows), and the composed (child) may be either
format — but a child that wants to export more than {runId, status} must
be authored in Markdown.
Different from a step's own output: schema. A step's output: (see
Typed step artifacts below) is a typed-artifact
contract on one step, enforced and retryable through that step's own gate
loop. A workflow's outputs: is a run-level export projection over
already-promoted step artifacts, resolved once, after every step has
finished. They compose freely: a step can declare output: and the
workflow's outputs: can project that same step's artifact under an export
name.
Resolution happens once, at run completion, over the run's persisted
step evidence — never re-evaluated afterward. If a declared from cannot
be resolved, reads a step artifact that was too large to persist in full, or
its resolved value fails its declared schema, run completion itself
rolls back: the run stays active, its final step stays pending, and no
event is emitted. Fix the declaration (or the step that produces the value)
and the next completion attempt resolves outputs again — a bad outputs:
entry cannot silently strand a run as completed with missing exports.
What a completed run exports. A run with an outputs: declaration
exports exactly those resolved values. A run with none exports
{runId, status} instead — synthesized whenever it is read, never
persisted. This is what a parent step composing this workflow as a child
sees at steps.<id>.output: see Child execution for how
a composing step's own reference into a child's exports is checked at
freeze time.
Typed step artifacts
When a step declares output, the promoted step artifact (the unit's
structured result, the collected array, or a reduced single value — see
What a step's output is above) is validated
against that schema before the step can complete. A mismatch fails the
step with the validation errors in its summary. This is fail-fast on purpose:
a bounded gate loop (see Gates and verification)
can re-run the step with those errors as corrective feedback.
The enforced JSON Schema subset
output and params schemas are validated as schemas at parse time
(akm lint --type workflows, akm workflow create), because the runtime
enforces only a subset of JSON Schema:
type, enum, properties, required, items, additionalProperties: false, minItems, maxItems, minLength, maxLength, minimum,
maximum, allOf, anyOf, oneOf, not.
Anything outside it is an authoring error, not a silent no-op. A typo'd
type name (type: strig) and a recognized-but-unenforced keyword ($ref,
$defs, const, pattern, format, patternProperties, if/then/else,
uniqueItems, multipleOf, exclusiveMinimum/exclusiveMaximum,
tuple-form items, schema-form additionalProperties, …) both fail with the
offending keyword named, a suggested replacement where one exists (const →
a single-value enum; $ref → inline the schema), and the location anchored
to the line. A gate that depends on a schema constraining nothing is worse
than a loud failure. Annotation keywords (description, title, default,
examples) constrain nothing in full JSON Schema either, so they pass through
untouched.
pattern is among the unsupported keywords. Matching an author-supplied regex
inside a synchronous gate decision would have to be bounded before the match
starts — a static safety analysis — and any such analysis also refuses regexes
authors legitimately write. Rather than carry machinery that fails authoring
for no benefit, the subset does not evaluate pattern at all and says so at
the point of authoring. Where a string's shape matters, list the allowed values
with enum, bound its size with minLength/maxLength, or check the shape in
the step's gate rubric, which can explain a mismatch in a way a regex cannot.
Evaluation is bounded: schema nesting is capped at 64 levels and one validation may make at most 100 000 checks. Exhausting either is reported as an error — a truncated evaluation never reports a value as valid.
Bounds
These are enforced identically by the parser, the published JSON Schema, and
the frozen-plan decoder (they share one set of constants in
src/workflows/resource-limits.ts), so a document that lints clean cannot
fail later at akm workflow run:
| Field | Bound |
|---|---|
gate.max_loops |
1 – 100 |
map.concurrency |
1 – 64 |
retry.max |
0 – 100 |
timeout |
≤ 2147483647 ms (~24.8 days), or none |
engine names |
^[a-z][a-z0-9]*(-[a-z0-9]+)*$, ≤ 63 chars |
timeout is resolved once, at freeze time, and the frozen value is what
dispatch applies — there is no separate engine-side ceiling on top of it. The
first of these that is set wins: the unit's timeout, then the document's
defaults.timeout, then engines.<name>.timeoutMs, then the engine-kind
default — 10m for kind: llm engines, and none for agent engines, which
manage their own process lifetime. Writing timeout: none is an explicit opt
out and leaves the unit genuinely unbounded; nothing later re-imposes a cap.
Exec (shell) units
A step whose unit: block declares exec: runs a shell command instead of
dispatching to an engine. Deterministic work — running the test suite, building,
linting, copying a file, invoking a script — is a command, not a prompt, and
paying for an LLM or agent dispatch to get it done buys nondeterminism, latency,
and tokens in exchange for nothing.
steps:
- id: test
unit:
exec:
command: ["bun", "run", "test:unit"]
cwd: packages/core # optional, relative
timeout: "10m"
retry: { max: 1, on: [timeout] }
An exec unit names no engine. It carries no engine, model, or llm (the
parser rejects those alongside exec:), it consumes no tokens, and a workflow
made only of exec steps freezes and runs on an install with no engine configured
at all. Everything else about a unit still applies unchanged: timeout, retry,
on_error, output, env, isolation, map fan-out and its concurrency, the
unit journal, budget accounting, and replay/reuse.
The step's body prose is still required (it is the step's section, like any other unit step) and is not passed to the command — it documents what the command does, for the human reading the workflow.
command is an argv array, never a shell string
There is deliberately no shell-string spelling. The child is spawned
directly, so nothing ever parses the words: ;, |, &&, $(…), backticks,
>, and * inside an argument are inert literal bytes. The entire
quoting/injection class a sh -c "<string>" surface opens is structurally
absent, not defended against — a value that happens to contain ; rm -rf /
is one argument containing those characters, and always was.
If you genuinely want a pipeline or a shell builtin, name the interpreter yourself:
command: ["bash", "-lc", "bun run build | tee build.log"]
That is allowed and sometimes right — but it is now a visible, reviewable line in the frontmatter diff rather than something the format did for you silently.
Bounds: 1–64 argv entries, each a non-empty string of at most 4096 bytes.
cwd
Optional and relative. It resolves inside the unit's working directory —
the engine invocation's working directory normally, or the unit's fresh
detached worktree under isolation: worktree. Absolute paths, Windows drive
letters, ~, and .. segments are rejected by the parser and by the
frozen-plan decoder, and containment is re-checked against the resolved base
(symlinks included) immediately before the command is spawned. An exec unit
cannot step outside the tree its isolation promised.
The output rule
-
No
output:schema → the artifact is the command's stdout, with trailing newlines stripped (exactly like shell$(…)). Empty stdout follows the ordinary empty-output rule: it is treated as no output. -
With an
output:schema on the unit → stdout must be exactly one JSON value (surrounding whitespace tolerated, nothing else), which is parsed and validated against the schema. This is a strict parse, unlike the forgiving embedded-JSON scan used for model output: a command that claims a schema prints JSON, and log noise that happens to contain a JSON object must never be promoted as the artifact. Non-JSON stdout fails asparse_error; JSON that misses the schema fails asvalidation_error. -
stderr is never part of the artifact. It is a diagnostic channel: the tail of a failed command's stderr is included (clipped, redacted) in the unit's failure diagnostic and its journal row. Because a failing command's stderr is frequently the only explanation of the failure, that diagnostic is durable — it survives to
akm workflow status --unitsand to the step's summary in the run output, not just to the in-memory result. It is clipped to 2000 characters and goes through the same redaction contract as everything else journaled. -
Retained output is bounded at 8 MiB per stream. akm keeps at most that much of stdout and of stderr; past the cap it keeps reading and discards, so the command still runs to completion and its exit code still decides the unit. See Output limits — the artifact is then explicitly marked truncated, never silently shortened, and only a unit with a declared
output:schema fails for it. -
An incomplete STDOUT capture is a failure, not a partial artifact. Exiting 0 is not on its own proof that stdout was fully read: a pipe can error, and a background descendant that keeps the stdout handle open after the command leader exits will hold the pipe past the drain deadline. Either way the captured text is a prefix of the real output, so the unit fails
exec_capture_incompleterather than promoting the prefix. That reason is deliberately outsideretry.on: the command already RAN, and re-dispatching identical argv to fix a capture problem would run its side effects again. If you hit this, have the command wait for its children or redirect their output.Only stdout is fatal here. stderr never contributes to the artifact, so a stderr drain that did not finish leaves the unit's actual result — a completed command, a fully captured stdout — intact and the unit succeeds; failing it would discard a valid artifact over a lost log tail. akm warns when that happens, naming the unit, so stderr shown for it is never mistaken for the whole of it.
The drain deadline itself starts when nothing living owns the pipe any more — the command's exit, or for a unit with a declared
timeoutthe earlier of that and the budget expiring — plus a short grace. A unit does not sit until its wholetimeoutbecause a descendant outlived the command.
Note that a schema failure is not retried by the corrective-feedback loop
that model units use. Re-prompting is meaningless to a fixed argv — the same
command cannot produce different output — but re-running it can deploy twice.
A declared retry: still applies, because that is a policy you opted into per
failure reason.
Exit codes and failure reasons
| Outcome | failure_reason |
In retry.on? |
|---|---|---|
| exit 0 | — (unit succeeds) | — |
| non-zero exit | non_zero_exit |
yes |
exceeded timeout |
timeout |
yes |
run cancelled (Ctrl-C, --timeout, budget) |
aborted |
yes |
| binary missing / working directory unusable | spawn_failed |
yes |
| stdout capture never completed | exec_capture_incomplete |
no (the command already ran) |
| stderr capture never completed | — (unit succeeds; a warning says the stderr tail may be missing) | — |
stdout past the retention limit, and the unit declares output: |
exec_output_limit |
no (deterministic) |
stdout past the retention limit, no output: schema |
— (unit succeeds; artifact marked truncated) | — |
AKM_* context too large for this platform to spawn |
exec_context_too_large |
no (an authoring/data problem) |
cwd resolved outside its base |
exec_cwd_escape |
no (tampering, never transient) |
A non-zero exit is an ordinary unit failure, so it flows through the ordinary
policy: with the default on_error: fail it fails the step and the run — which
is exactly what makes a test step a gate. With on_error: continue the
failure is recorded in the step's evidence and the completion gate decides.
On timeout or cancellation the child's whole process group gets a SIGTERM→SIGKILL ladder, so a command that spawned its own children does not leave them orphaned.
Output limits
akm captures the command's stdout and stderr into memory — stdout is the artifact — so it retains at most 8 MiB of each stream.
This is a cap on akm's memory, not on your command. Past the cap akm keeps reading the pipe and throws the extra bytes away, so the command never blocks on a full pipe: it runs to completion and its exit code is what decides the unit. A chatty-but-passing test suite is not failed for its log volume.
What overflow does cost is honesty about the artifact, and that depends on what the unit promised:
| The unit declares… | On overflow |
|---|---|
no output: schema |
the unit succeeds (given exit 0). Its artifact is the retained first 8 MiB with a __akm_exec_output_truncated__ block appended, naming the bytes written and the bytes retained. |
an output: schema |
the unit fails exec_output_limit and nothing is promoted. |
Nothing is ever truncated silently. The marker block is the last thing in the
artifact, so a downstream steps.<id>.output reference, a completion gate's
judge, and a human reading akm workflow status all see plainly that the text is
incomplete. Truncated data can never be mistaken for complete data — that is the
rule, and the marker is how it is kept.
The schema case stays a failure because a truncated prefix is not a JSON value:
with output: declared, stdout must parse as exactly one JSON value, so there is
nothing to validate and nothing safe to promote. exec_output_limit is therefore
still outside the retry.on vocabulary — the command is deterministic, so
re-dispatching it can only spend the budget to produce the same oversized output
again.
stderr overflow never fails anything. stderr is a diagnostic channel; the journal already clips and marks what it keeps.
The cap is generous (8× the 1 MiB evidence-persistence cap), so an ordinary build or test log is nowhere near it. If a command legitimately produces more and you want the whole thing, have it write to a file and print the path:
command: ["bash", "-lc", "bun run build > build.log 2>&1; echo build.log"]
Context reaching the command
A frozen argv is never interpolated (this format has no substitution language), so data reaches an exec unit as environment, the argv analogue of the context blocks a model unit gets in its prompt:
| Variable | Value |
|---|---|
AKM_RUN_ID, AKM_STEP_ID, AKM_UNIT_ID |
ids of this dispatch |
AKM_PARAMS |
the run params, canonical JSON |
AKM_ITEM, AKM_ITEM_INDEX |
a map unit's item (canonical JSON) and 0-based index |
AKM_INPUTS |
the step's declared inputs: artifacts, keyed by reference string |
AKM_TASK_INPUTS |
a task-composed step's resolved with: bindings (canonical JSON), present only when the bindings are non-empty |
These are applied after your env: bindings, so a binding can never shadow
them.
Context size limits
An environment variable is an operating-system object with a hard ceiling, and a workflow artifact has no comparable bound — so a perfectly legitimate declared input can grow past what process creation itself accepts. akm therefore bounds what it puts in the child's environment, against the ceiling of the platform the run is actually on:
| Bound | Linux / macOS / BSD | Windows |
|---|---|---|
One AKM_* context variable |
98 304 bytes (96 KiB) | 32 767 bytes |
All AKM_* context variables combined |
131 072 bytes (128 KiB) | 64 000 bytes |
Where those numbers come from:
- Linux caps a single
argv/environstring atMAX_ARG_STRLEN, defined as32 * PAGE_SIZE— 131 072 bytes. The 96 KiB bound leaves 32 KiB of margin for the variable's name, the=, theNUL, and the kernel's own accounting. - macOS has no per-string cap; its binding constraint is
ARG_MAX(262 144 bytes) overargv+environcombined. The 128 KiB total keeps akm's own contribution to half of that, leaving the rest for the argv, the environment allowlist and yourenv:bindings. - Windows caps a single user-defined environment variable at 32 767
characters (
SetEnvironmentVariable), and the environment block has limits of the same order.
Crossing either bound fails the unit exec_context_too_large before anything
is spawned, with an error naming the variable, its actual size, this platform's
limit and where that number comes from. That translation is the check's only
job: without it the same workflow dies inside the spawn syscall with a bare
E2BIG ("argument list too long") that names neither the variable nor the step
that produced the data.
Because that is its only job, the check uses this platform's ceiling rather than the smallest supported one. A guard that applied Windows' 32 767-byte limit on Linux would refuse spawns the kernel would happily have accepted — inventing a failure instead of explaining an inevitable one.
Portability guidance (not enforcement). If a workflow is meant to run on Windows as well, keep
AKM_*context under 32 767 bytes per variable. akm will not fail your Linux or macOS run for exceeding that — but a Windows runner will. The fix in both cases is the same: emit a reference instead of bulk data.
If you hit this, have the producing step emit a reference — a file path, an id, a key — instead of inline bulk data:
- id: extract
unit:
exec:
command: ["bash", "-lc", "./extract.sh > /tmp/rows.json; echo /tmp/rows.json"]
- id: load
inputs: [steps.extract.output] # a PATH, not the rows
unit:
exec:
command: ["./load.sh"]
akm deliberately does not transparently spill an oversized context to a file
and pass a path instead. That would make the AKM_INPUTS contract conditional
on the size of the data — sometimes JSON, sometimes a filename — so every
command would have to handle both shapes, and the spill file would have to be
placed, isolated and cleaned up inside a unit's worktree. A stable contract plus
an explicit error is the smaller, more predictable surface.
The child's environment is an allowlist
An exec unit's command does not inherit akm's environment. The child starts from an empty environment and is built in three layers, in this order:
- the default allowlist below, copied through from akm's own environment
(plus any names the unit adds with
pass_env:); - the unit's resolved
env:bindings; - the engine-authored
AKM_*context — last, so a binding can never shadow it.
The default allowlist is exactly these names (a name absent from akm's own environment is simply absent from the child):
| Group | Names | Why |
|---|---|---|
| Command resolution | PATH |
without it only an absolute command[0] can be spawned |
| Home | HOME |
the config/cache root git, npm, bun, cargo and ssh all read |
| Identity | USER, LOGNAME, SHELL |
read by git/ssh and by tools that re-exec a login shell |
| Locale | LANG, LC_ALL, LC_CTYPE |
without a locale, non-ASCII stdout — which is this unit's artifact — gets mangled |
| Terminal / clock | TERM, TZ |
some CLIs abort with no TERM; TZ keeps printed timestamps stable |
| Scratch space | TMPDIR, TEMP, TMP |
POSIX and Windows temp roots |
| Windows essentials | SystemRoot, SystemDrive, WINDIR, COMSPEC, PATHEXT |
Windows process creation itself fails with an empty environment; PATHEXT is what makes bun.exe/bun.cmd resolvable at all |
| Windows home/config | USERPROFILE, HOMEDRIVE, HOMEPATH, APPDATA, LOCALAPPDATA, ProgramData, ProgramFiles |
the Windows analogues of HOME and the machine-wide install roots toolchain shims resolve against |
| akm provenance | AKM_EVENT_SOURCE |
a command that calls akm records machine traffic, not user demand |
PATH is additionally supplemented with well-known user binary directories
when akm is running in a scheduler context (cron/launchd/Task Scheduler) that
stripped it — the same treatment an agent-harness child gets.
Deliberately not on the list: credentials of any kind, cloud/CI variables,
and the proxy family (HTTP_PROXY and friends — proxy URLs routinely embed
credentials). Reach a required value with an exact named env: binding, or
name a non-secret per-machine variable with pass_env:.
pass_env: — widen the allowlist by name
unit:
exec:
command: ["cargo", "build", "--release"]
pass_env: [CARGO_HOME, SCCACHE_DIR]
Names only, 1–32 of them, matching ^[A-Za-z_][A-Za-z0-9_]*$. Use it for a
per-machine variable an env: binding cannot express — an env asset stores
a committed value, so it cannot carry "whatever this build agent's
CARGO_HOME happens to be".
Values passed through this way are not redacted from the command's output
the way env: binding values are, so never list a credential here.
Durable v4 (irVersion: 5) forbids inherit_env
Every new workflow start freezes the durable plan v4 family's current
executable format, irVersion: 5. It rejects inherit_env: true and any other
request for whole-process inheritance; use exact named environment bindings
and pass_env: instead. Both mechanisms are dispatch-significant, keep the
visible environment surface bounded, and form part of the unit's input hash.
The historical inherit_env spelling is unsupported.
What akm show reports for an exec step
akm show <workflow> --format json summarizes each step under
steps[].orchestration. For an exec step that summary carries an exec object
and no engine/model — an exec unit names no engine, so reporting the
workflow's defaults.engine there would describe a dispatch that never
happens. Field presence is the discriminator, the same way fanOut marks a
map step and route marks a route step:
{
"id": "test",
"title": "test",
"instructions": "Run the unit tests.",
"orchestration": {
"timeoutMs": 600000,
"exec": {
"command": ["bun", "run", "test:unit"],
"cwd": "packages/core",
"passEnv": ["CARGO_HOME"]
}
}
}
commandis the argv in full, never clipped — the point of the field is that whatshowprints is what runs, and a truncated argv would be the same misdescription in miniature. It is safe to print because it is authored literally in the asset: this format has no substitution language, so no part of it is resolved from your environment, from a secret ref, or from a prior step's output. Every byte is already visible in the workflow file (and stored verbatim inplan_json) — which is also why you never inline a secret there.cwdandpassEnvappear only when the unit declares them.passEnvis a list of variable names; no value is ever projected. New v4 plans never carryinheritEnv.timeoutMsis still reported, because an exec unit really does inheritdefaults.timeout— that number is true for it.
Everything else in the summary is unchanged: a map of exec units carries both
fanOut and exec, and hasSchema/env mean what they mean for any unit.
Security
Exec units sit inside the existing workflow trust model — see Security: workflow sources are executed code. They do not widen it, and they do not narrow it:
- The child's environment is an allowlist, not an inheritance. Be clear
about what that does and does not buy. It does not stop a determined
attacker: a command that runs at all can read the same credentials off disk
that the environment would have handed it, and a workflow source is executed
code either way. What it does buy is real but narrower — it bounds
accidental exposure (the shell or CI job that invoked
akmroutinely exports tokens for unrelated services, and a third-party step that merely prints its environment, or a tool that ships one in a crash report, should not get them for free), it makes the environment surface explicit and reviewable (this list plus lines in the frontmatter diff, rather than "whatever the invoking shell happened to export"), and it matches the convention akm already applies to spawned children — agent-harness children have always been built fromenvPassthroughthis way, and exec units now use the same mechanism rather than a second one. Operators who need a harder boundary still scope the akm process (dedicated account, ephemeral working directory, external network/filesystem policy); that is the boundary that actually holds. - Secrets come from
env:bindings by name. The frozen plan carries only the ref names, the replay hash carries only the ref names, and the resolved values are collected and scrubbed out of stdout, stderr, and the failure diagnostic by the same redaction contract every other dispatch uses — before anything is journaled. Never inline a secret intocommand:; argv is stored verbatim inplan_json. - Read a workflow before you run it.
exec:makes what a workflow will run explicit and auditable in one place, which is a real improvement over instructing a model to "run the tests" — but a bundle you do not trust is still a stranger's script.
Fan-out and concurrency
A map step is a fan-out: it expands over: into one unit per item, runs
those units, and folds the results with its reducer. The units are
independent by construction — no unit can read another's result — so they run
in parallel by default.
The default
Since 0.9.1, a map step that declares no concurrency: freezes a width of
4. (Before 0.9.1 it froze 1, so every fan-out ran one item at a time unless
the author opted in.) 4 rather than "as wide as the machine allows" is
deliberate: it is a predictable 4× on any fan-out longer than four items, it
stays under the host CPU cap on any machine with 6 or more cores, and it is a
number an author can reason about without knowing which box the run lands on.
Three ways to change it:
| You want | Write |
|---|---|
| A specific width for one step | map.concurrency: <n> in that step |
| Serial execution for one step | map.concurrency: 1 |
| A different default for every workflow on this machine | akm config set workflow.defaultMapConcurrency <n> |
concurrency: 1 is a real, honored opt-out, not the absence of a value: an
authored 1 is kept distinct from an unset field, and it always beats the
config default. Set workflow.defaultMapConcurrency to 1 to restore the
pre-0.9.1 serial-by-default behavior everywhere at once.
A step with no map: is one unit, not a fan-out. It is unaffected by any of
this.
The four limits
The width a step really runs at is the minimum of four independent values. Raising one never raises the others:
| Limit | Set by | Default when unset |
|---|---|---|
map.concurrency |
the step | workflow.defaultMapConcurrency, else 4 |
execution.maxConcurrency |
workflow.maxConcurrency config |
CPU-derived min(16, max(1, cores − 2)) |
| the selected LLM engine's concurrency | engines.<name>.concurrency |
1 for a loopback endpoint, 4 for a remote one |
| the host CPU safety cap | nothing — reapplied at dispatch | min(16, max(1, cores − 2)) |
The engine limit is per endpoint kind on purpose. A local model server (LM
Studio, Ollama) has one loaded model, and concurrent inference makes it reload
and return HTTP 500 — a hard failure, so loopback endpoints stay at 1 and a
map against a local model is still effectively serial unless you raise
engines.<name>.concurrency yourself. Remote providers fail softly (a
retryable 429), and four concurrent completions is well inside any hosted
provider's entry tier. Agent engines carry no concurrency limit of their own —
except an opencode-sdk engine that sets llmEngine, which inherits that
fallback engine's limit.
What counts as a loopback endpoint
The whole loopback space, not one address:
| Recognized | Examples |
|---|---|
all of 127.0.0.0/8 |
http://127.0.0.1:1234, http://127.0.0.2:11434 |
localhost and any *.localhost name |
http://localhost:1234, http://lmstudio.localhost |
IPv6 ::1, in any spelling |
http://[::1]:1234, http://[0:0:0:0:0:0:0:1]:1234 |
| the unspecified addresses (a client connecting there reaches loopback) | http://0.0.0.0:11434, http://[::]:11434 |
127.0.0.2 matters in practice: running a second LM Studio or Ollama on
another address inside the 127.0.0.0/8 block is ordinary, and that server is
exactly as single-model as one on 127.0.0.1.
IPv4-mapped IPv6 is recognized for 127.0.0.0/8 in both spellings —
[::ffff:127.0.0.1] and the hex [::ffff:7f00:1] — because the URL parser
re-serializes the dotted form to the hex one before the check ever sees it. A
mapped public address such as [::ffff:8.8.8.8] still reads as remote. The
deprecated IPv4-compatible form [::127.0.0.1] is not recognized; if you use
it, set engines.<name>.concurrency: 1.
The check is purely syntactic — it never resolves a name. A frozen plan has
to come out the same on your laptop, on CI, and on a machine with no network at
all, and a DNS lookup would make the frozen width depend on what a resolver
happened to answer. So a name that resolves to loopback (a hosts-file alias,
host.docker.internal, an internal DNS record) is treated as remote; point
the engine at the address itself, or set engines.<name>.concurrency: 1
explicitly. In the other direction the classification is deliberately
conservative: an endpoint akm cannot parse at all is treated as loopback, since
freezing 4 for a config it does not understand is the failure worth avoiding.
The host cap is re-derived from the CURRENT machine at every dispatch, not frozen. A plan frozen on a 32-core CI box narrows itself when it resumes on a 4-core laptop.
Frozen widths
Every one of these numbers except the host cap is resolved once, when the run starts, and stored in the run's plan. Editing config, upgrading akm, or changing the defaults above never alters a run that is already in flight or being resumed — it keeps the widths it froze. The new defaults apply only to runs started after the upgrade.
Routing
A route step makes classify-and-dispatch first-class: the engine resolves
the explicit input: expression, selects the matching when: branch (or
default:), and auto-skips the unselected branch targets as the spine
reaches them. Routes are forward-only: every target (each when.step
and default) must be a step declared later in the workflow than the
routing step, and a step never routes to itself — this keeps the plan a DAG,
so termination is structural rather than a runtime budget's job. A
default: that names an earlier step is a lint error, not a loop. An
unroutable value with no default fails the step rather than letting every
branch run.
"Go back and fix it" is a gate, not a backward route. A failed gate
re-runs its own step with the judge's feedback, bounded by gate.max_loops
— and a declared output: schema the promoted artifact fails is specifically
the error a gate loop retries through. A workflow that used to describe "loop
back to an earlier step until this passes" expresses that as a bounded gate
on the step doing the work, not as routing.
Route decisions are journaled, so a resumed run replays the same choice. Skips cascade: when a route step is itself skipped (it was the unselected target of an earlier route), its own branch targets are skipped too — a router that never decided selects nothing.
Failure policy
Fail-fast is the default. Per unit (or via defaults.on_error):
on_error: fail— the first failed unit fails the step, which fails the run (akm workflow resumere-opens it;akm workflow runre-dispatches only incomplete units).on_error: continue— failures are recorded in the step's results and the completion gate decides whether the step passes.retry: { max: <n>, on: [<failure_reason>…] }— re-dispatches a failed unit up tomaxextra times when its recordedfailure_reasonis listed (e.g.timeout,llm_rate_limit,spawn_failed,non_zero_exit); every attempt is journaled separately. For an exec unit a non-zero exit isnon_zero_exit, a wall-clock expiry istimeout, and a failure to start isspawn_failed.
A unit's output schema is validated on every runner; a validation miss
re-dispatches once with corrective feedback before the unit is recorded as
failed. Exec units are the one exception: a fixed argv cannot answer feedback,
so a schema miss fails immediately rather than re-running a side-effecting
command (see The output rule).
Gates and verification
Gates judge the artifact; max_loops bounds the retry. Under
akm workflow run, a step with a body ### gate rubric is gated on its
artifact, not on engine prose: the judge receives the step's artifact as
canonical JSON (clipped at 4000 characters) alongside the ### gate section
byte-exact, so the gate evaluates real results rather than a machine summary
like "Executed 3 units". Each engine-driven gate evaluation is itself an LLM
call and is journaled as its own unit row.
gate.max_loops: <n> (frontmatter) turns the gate into a bounded
evaluator-optimizer loop: on a rejection (or a typed-artifact schema
mismatch) with loop budget left, the engine re-executes the step's units
with the gate feedback and the missing-criteria list appended as attached
context. The feedback changes each unit's inputs, so the re-run naturally
dispatches fresh units instead of replaying journaled results. When the loop
budget is spent, the rejection stands exactly as in the one-shot case.
An exec step is judged, but never looped. A gate loop
earns its re-dispatch by handing the judge's feedback to a unit that can answer
it, and an exec unit cannot: its argv is frozen and never interpolated, and the
AKM_* context carries no feedback variable. A second loop would re-run the
byte-identical command — deploying, publishing, or migrating twice — for a
verdict that cannot change. So a step whose unit is exec: still has its
artifact judged and can still be failed by the verdict; a rejection simply lands
on the first evaluation, carrying the same missing criteria and feedback the
one-shot case does. gate.max_loops on such a step is capped at 1 rather than
rejected, and an engine step's declared max_loops is untouched. This is the
same reasoning that makes an exec unit's output: schema miss fail without a
corrective re-dispatch.
Fail-closed verification. With no non-empty ### gate rubric, no
verification runs. When a rubric is present, the workflow requires
workflow.judgeEngine to name a configured LLM or agent engine before the
plan can be frozen — see
Author's Guide: Troubleshooting
if that engine isn't configured yet. That verifier invocation is frozen into
the run.
Only a well-formed complete: true verdict advances a criteria-bearing step.
A missing verifier, dispatch failure, or malformed result rejects the gate
instead of silently bypassing it. A well-formed complete: false verdict
returns its missing criteria and feedback and can trigger another bounded
max_loops attempt.
Budget ceilings
The top-level budget: key declares run-lifetime ceilings: max_units
(total dispatched units) and max_tokens (total reported token usage). Both
counters are seeded from the unit journal, so they measure the whole run
across resumes, not just the current invocation. Hitting a ceiling aborts
the step's still-pending dispatches and fails the step with a
budget exceeded (<which> ceiling) summary — budget exhaustion is a hard
stop that ignores on_error: continue. Because the plan is frozen, raising a
budget means starting a new run.
Model references
Reference semantic aliases in model: fields instead of exact model ids so a
workflow stays harness-agnostic. Aliases are defined only in the installed and
optional user models.json files:
{
"version": 1,
"aliases": {
"fast": { "claude": "claude-haiku-4-5", "opencode": "opencode/claude-haiku-4-5" },
"balanced": { "claude": "claude-sonnet-4-6", "opencode": "opencode/claude-sonnet-4-6" },
"reasoning": { "claude": "claude-opus-4-7", "opencode": "opencode/claude-opus-4-7" }
}
}
Resolution checks the selected engine name or canonical harness column. A
known alias without that column fails rather than guessing a provider model;
an unknown string is treated as an exact model selector. A column may itself
be an engine-backed indirection (0.9.15) rather than a literal model string
— see Model-map files — so a step naming
model: fast also benefits from whatever engine-backed mapping the operator
configured for the selected engine, with no workflow-authored change. See the Author's Guide
for guidance on which tier to pick per step.
See also
- Running Workflows — operating a run day to day
- Author's Guide: Writing Workflows — a task-oriented walkthrough
- Architecture: The Workflow Engine — persistence, dispatch, and resume internals
- CLI Reference — full flag documentation for
workflowandlint