akm docs

Author's Guide: Writing Workflows

This guide walks through writing and testing a workflow definition: choosing a source format, the Markdown structure, a minimal complete example, common authoring mistakes, and how to verify gates and outputs before you publish. It assumes you already know what a workflow is; for the exhaustive, exact-syntax reference — every frontmatter key, the reference grammar, gates, and outputs — see Workflow Schema. For operating a run once it's written, see Running Workflows.

Start from the template

Authors can choose peer Markdown or GitHub-shaped YAML sources. Markdown is the full AKM authoring format: OKF-conformant frontmatter carries the orchestration graph and its body carries instructions and gate rubrics. GitHub-shaped YAML is a strict local subset for interoperability. Read the authoritative YAML subset before translating an Actions-shaped file; AKM does not accept arbitrary GitHub semantics. .yaml is not recognized—use .yml.

Use akm workflow create --print to print a valid Markdown starter, then edit it and register it with akm workflow create. Author a YAML source directly under workflows/<name>.yml and validate either source with akm lint:

akm workflow create my-release --print   # Print the template, without writing
akm workflow create my-release --from ./my-release.md
akm lint --type workflows                # Check for structural errors before using it

Both formats compile to source IR version 1. Choose Markdown for maps, routes, gates, typed artifacts, and prose-rich agent instructions. Choose the bounded YAML subset for a single local self-hosted job made of token-safe run and supported local uses steps.

A minimal complete 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.

Walking through it: validate and build are both bare unit steps — neither declares unit:, map:, or route:, so each is "still a unit step," the minimal declaration. build names steps.validate.output in inputs:, so the engine attaches validate's result to build's dispatched context, and build's own re-dispatch on a resumed run is keyed to that exact slice — not the whole run. validate has a ### gate; build does not, so build completes as soon as its unit succeeds, with no verification pass.

For a richer example — fan-out with map, route-based branching, retries, and a run budget — see Workflow Schema: Richer example.

Deterministic steps: run a command, gate on it

Not every step needs a model. Running the test suite, building, linting, or invoking a script is deterministic work: there is exactly one right answer and the command already knows it. A step whose unit: declares exec: runs that command directly — no LLM, no agent, no tokens, no nondeterminism.

The canonical shape is a test step that gates the rest of the run:

---
type: workflow
description: Fix a failing test, then verify the suite is green
params:
  failure: { type: string, description: The failing test name or error }
steps:
  - id: fix
  - id: test
    inputs: [steps.fix.output]
    unit:
      exec:
        command: ["bun", "run", "test:unit"]
      timeout: "10m"
      retry: { max: 1, on: [timeout] }
  - id: report
    inputs: [steps.test.output]
---

# Fix and Verify

## fix

Find the cause of the failure described in the run parameters and fix it.
Explain what you changed and why.

## test

Run the unit test suite. This step is deterministic: the command's exit code
decides whether the run continues.

## report

Summarize the fix and the test results attached to this unit.

What this buys you:

Getting a typed result instead of raw text

If the command can print JSON, declare an output schema on the unit and the step artifact becomes a validated structure:

  - id: test
    unit:
      exec:
        command: ["bun", "run", "test:unit", "--reporter=json"]
      output:
        type: object
        required: [passed, failed]
        properties:
          passed: { type: number }
          failed: { type: number }

stdout must then be exactly one JSON value — no log noise around it — and downstream steps can address steps.test.output.failed.

What the command can see

The child does not inherit your environment. It starts empty and gets a small default allowlist — PATH, HOME, the locale/temp/identity variables, and the Windows essentials (SystemRoot, COMSPEC, PATHEXT, …) that process creation itself needs — then your env: bindings, then the AKM_* context variables. Ordinary commands (bun, git, make, cargo) work unchanged; an unrelated SOME_OTHER_SERVICE_TOKEN sitting in the shell that ran akm workflow run does not reach them.

Widen it only by naming what the unit needs:

  - id: build
    unit:
      env: [env/build]
      exec:
        command: ["cargo", "build", "--release"]
        pass_env: [CARGO_HOME, SCCACHE_DIR]   # a few extra names

pass_env: is for a per-machine variable an env: binding cannot express (an env asset stores a committed value; CARGO_HOME differs per build agent). Secrets and fixed values belong in exact named env: bindings: those values are redacted out of everything journaled, and pass_env: values are not.

Workflow starts freeze the durable plan v4 family's current executable format (irVersion: 5), which rejects inherit_env instead of persisting an unbounded ambient environment. Pre-irVersion-5 stored plans do not execute; start a new run after updating the source.

Named bindings and pass_env: are part of the unit's input hash, so changing either re-runs the command instead of reusing a row recorded under another scope.

Full list of allowlisted names: Workflow Schema: The child's environment is an allowlist.

Things to get right

Full reference: Workflow Schema: Exec (shell) units.

Common authoring mistakes

Typing what a step returns

A step's output: (and each params: entry) is a JSON Schema, and the engine validates the step's artifact against it before the step can complete. The runtime enforces a bounded subset of JSON Schema — anything outside it is an authoring error at akm lint, never a silent no-op, because a gate depending on a schema that constrains nothing is worse than a loud failure.

Enforced: type, enum, properties, required, items, additionalProperties: false, minItems, maxItems, minLength, maxLength, minimum, maximum, and the combinators allOf, anyOf, oneOf, not.

  - id: release
    output:
      type: object
      required: [version, verdict]
      additionalProperties: false
      properties:
        version: { type: string, minLength: 1 }
        verdict: { type: string, enum: [pass, fail] }
        detail:
          oneOf:
            - { type: string, minLength: 1 }
            - { type: "null" }

Outside the subset (each fails lint with the keyword named, its line, and a suggested replacement where one exists): $ref/$defs — inline the schema instead, since nothing resolves references; const — use a single-value enum; pattern and format — no regex or string-format constraint is evaluated at run time, so list the allowed values with enum when you can, bound the size with minLength/maxLength, and otherwise check the shape in the step's ### gate rubric, which can also say why a value is wrong; patternProperties, if/then/else, uniqueItems, multipleOf, tuple-form items, and schema-form additionalProperties (only additionalProperties: false is enforced). Annotation keywords — description, title, default, examples — always pass through untouched, and documenting each property is worth the keystrokes.

If lint reports a very broken workflow, note that its errors are sorted by line, so fix from the top and re-run — the later ones are usually fallout from the first.

Choosing engines and models

Set defaults.engine/defaults.model (or per-unit unit.engine/unit.model) rather than hardcoding an exact model id, so the workflow stays harness-agnostic. Reference semantic aliases — fast, balanced, reasoning, or aliases defined in the installed/user models.json files — in model: fields; see Workflow Schema: Model references for the exact resolution order and config shape.

Point reasoning work at a capable model and keep high-volume fan-out units on fast/balanced. The richer example's review map step is a good template: reasoning on the per-item reviewer, balanced as the run default for everything else.

Fan-out width

A map step runs its items in parallel by default — 4 at a time as of 0.9.1. (Earlier versions defaulted to 1, so a fan-out over 500 items crawled through them one by one unless you said otherwise.) Map units are independent by construction, so parallel is the honest default; the number is a modest 4 rather than "whatever the machine can take" so it stays predictable across the machines a workflow gets shared with.

Say so explicitly when the default is wrong for a step:

  - id: review
    map:
      over: steps.discover.output.files
      concurrency: 1     # serial — this step's units are NOT independent

concurrency: 1 is a genuine opt-out and always wins. Reach for it when the units touch a shared resource, when their side effects must happen in list order, or when they hit an external service you must not burst. To change the default for every workflow on a machine instead — including restoring the old serial behavior wholesale — set akm config set workflow.defaultMapConcurrency 1.

The declared width is a ceiling, not a promise. Three other limits clamp it and the smallest wins: workflow.maxConcurrency, the selected engine's engines.<name>.concurrency, and the host's CPU cap. The engine limit is the one that surprises people: an LLM engine pointed at localhost defaults to 1, because local model servers hold a single loaded model and fall over under concurrent requests — so a map against a local model stays serial no matter what the step declares, until you raise that engine's own concurrency. Remote LLM endpoints default to 4. See Workflow Schema: Fan-out and concurrency for the full table.

All of it is frozen into the run when it starts, so an in-flight or resumed run keeps the widths it began with even if you edit config or upgrade akm.

A workflow that fans out is authorizing N parallel agents, not one — the same trust model described in Running Workflows: workflow sources are executed code applies with multiplied blast radius. Give the workflow explicit safety and parameter metadata (document every params entry, keep destructive steps described plainly in the body) so a reader — human or agent — can judge that blast radius before running it.

Verify before you publish

  1. Lint the structure.

    akm lint --type workflows
    

    This catches the body-rule violations above, plus every static reference check (unknown step, unknown param, bad path, backward route).

  2. Run it for real. A dry inspection of the markdown doesn't tell you whether a gate actually rejects bad output or an output schema actually matches what units return. Run the workflow against representative params:

    akm workflow run workflows/my-release --version 1.2.3
    
  3. Inspect the evidence. Check that each step's promoted artifact is what you expect, and that a gate's rubric is judging that artifact, not engine prose:

    akm workflow status <run-id> --units
    

    --units shows per-unit diagnostics (status, failure_reason, raw result/error text) without polluting the deterministic artifact a gate judges — see Running Workflows: Check status.

  4. Deliberately break a gate once. Run the workflow with params you expect to fail validation, and confirm the gate actually rejects rather than silently passing — a missing workflow.judgeEngine or a malformed verdict rejects the gate rather than bypassing it (see Workflow Schema: Gates and verification), so this is worth confirming once per workflow rather than assuming.

Composing a task with typed inputs

A uses: tasks/<ref> step in the GitHub-shaped YAML format can bind a task source v4 target's declared inputs: through with:. Given this task:

# tasks/ticket-review.yml
version: 4
name: Ticket review
inputs:
  ticket:
    type: string
    required: true
  scope:
    type: string
    enum: [changed, all]
    default: changed
uses: commands/review

a workflow step can bind ticket from an EARLIER step's output, and override scope with a literal:

# workflows/nightly.yml
name: Nightly review
on:
  workflow_dispatch: {}
jobs:
  main:
    runs-on: [self-hosted]
    steps:
      - id: pick
        run: echo T-42
      - id: dispatch
        uses: tasks/ticket-review
        with:
          ticket: { from: "steps.pick.output" }
          scope: all

{from: "steps.<id>.output(.<segment>)*"} and {from: "params.<name>"} are the only two reference roots; anything else, or an object carrying from plus any other key, is INPUT_BINDING_INVALID at freeze — never silently reinterpreted as a literal. pick's output is resolved just before the dispatch unit dispatches, then validated against ticket's declared schema; a literal (like scope: all here) is validated at freeze instead, before the plan is ever published.

The composed target — commands/review here — receives the resolved bindings on whichever delivery surface matches its kind: an akm/command/commands/<ref> target gets a ## Task inputs block appended to its prompt; a run: shell or scripts/<ref> target gets one AKM_TASK_INPUTS environment variable (canonical JSON of the resolved bindings). Inspect exactly what each step's with: would deliver, without running anything, with akm workflow plan workflows/nightly --format json — its per-step inputBindings show dispatch's scope as the literal all and ticket as the unresolved reference steps.pick.output, the same shape freezing a real run would produce. akm task explain is not a substitute here: it only reflects a task's OWN CLI flags, declared defaults, and schedule[].inputs — it never reads a workflow step's with: binding at all, so pointing it at tasks/ticket-review prints scope's task-level default (changed), not the all this step actually sends.

Troubleshooting

Every workflow run needs a selected engine. Freezing resolves an engine for each unit. With no defaults.engine, akm falls back to a config-free opencode-sdk engine — provider, model, and auth come from opencode's own configuration — and announces it once in the run's warnings. The fallback needs the opencode binary on PATH: the bundled @opencode-ai/sdk package is an HTTP client only and spawns opencode serve to have something to talk to, so installing the npm package alone is not enough. With no binary, freezing fails with INVALID_CONFIG_FILE and exit 78.

A workflow with a non-empty ### gate additionally requires workflow.judgeEngine to name a configured LLM or agent engine — the gate judge is not covered by the fallback.

akm setup normally selects a default execution engine. On a bare container or CI image, either install opencode and let the fallback apply, or choose an engine explicitly:

npm i -g opencode-ai           # fallback route: puts `opencode` on PATH
# ...or pick an engine yourself:
akm config set engines.claude '{"kind":"agent","platform":"claude"}'
akm config set defaults.engine claude

See also