Durable state-machine workflows from plain files.
Work arrives as a message, a YAML statechart decides what happens next, and scripts do the work. Every step is written to an append-only log, so a run survives crashes, restarts and week-long waits for a reply, and continues where it stopped: durable execution in the spirit of Temporal or AWS Step Functions, but local, with one binary and a folder of files instead of a server and a database.
decree works with no AI at all. When you do use AI, decree is the harness around it: a model, or a person, only ever picks among the options a state declares, and the state machine stays in charge of what happens next.
decree is developed with itself: every change to this repository is a migration in .decree/migrations/, run by decree process.
decree is built from three blocks:
- Messages are markdown files that say what to do. The body is the task; the frontmatter names the machine that does it. One message is one run.
- Machines are YAML statecharts that say in what order: states, what each state invokes, and which event leads where. They follow W3C SCXML, written in YAML, and contain no code and no paths.
- Scripts are executables that do one piece of work and report one outcome. Bash by default.
Decisions are states of their own, cheapest first: a deterministic check, then a model (a local classifier or an LLM), then a person. Everything else is deterministic, and every step is recorded in each run's events.jsonl.
decree emit
+-------------------------------+
| v
| Message (frontmatter = structured, body = unstructured)
| | names machine; decree mirrors state
| v
| Machine (states, invokes, transitions) <-- options / one event --> model or person
| | script name + DECREE_* env ^ event: exit code or $DECREE_EVENT_FILE
| v |
+-- Scripts (scripts/<name>, bash by default)
curl -fsSL https://raw.githubusercontent.com/jtmckay/decree/main/install.sh | shThis installs the newest stable release from 0.5.0 on (while there is none, the newest beta) as a static Linux x86-64 binary in $XDG_BIN_HOME (default ~/.local/bin), with no sudo, after checking its sha256; run it again to update, or pin a version with | sh -s -- v0.5.0. Elsewhere (macOS, ARM), build from a tag: cargo install --git https://github.com/jtmckay/decree --tag v0.5.0. crates.io has only an older decree until 0.5.0 is published there, so cargo install decree is not this version.
decree runs on Linux and macOS.
Coming from 0.4? 0.5 is a rewrite, and there is no upgrade path or upgrade tool: finish pending 0.4 work with 0.4 first. 0.5 does not read a 0.4 .decree/; what changed is in the changelog's 0.5.0 entry.
Every command in this section runs as written, in order, in an empty directory.
1. Set up the project.
decree initThis writes .decree/ with the develop, infra and qa machines and their scripts, the router machine for your AI tool (--ai claude|copilot|opencode; by default the first one found on PATH), lib/ai.sh (the ai function the scripts source, with a function per backend: set AI_CLI=opencode in .decree/.env to switch; it waits out Claude's usage limit and logs every call in .decree/ai-use.md, as The AI helper says), and the decree skill for Claude Code or Copilot. Each built-in machine works in a git repository: a run starts only from a clean work tree, and a run that ends in done is one commit, titled with the message's first heading (D63). Commit or stash your own changes before queuing work. A run that ends in failed keeps its changes in the work tree, and its file in .decree/attention/ holds the queue, so no other run starts on them: decree process --retry <id> continues the run on its own work and removes the hold, or stash the changes and delete the file to set them aside. That folder has a file for every run that needs a person (failed, interrupted, or waiting for a reply), with the command that continues it; decree process and decree status point to it (Attention, D67). For divergent work, or to keep editing while runs go on, run decree in a git worktree you make: git worktree add ../app-exp -b exp, then decree process there. Each checkout has its own runs and inbox, and its branch its own processed.md, so the two never meet until you merge. A worktree holds committed files only: copy .decree/.env (and any app .env) in, and run .decree/lib/infra/run.sh apply there first.
infra changes the environment the project needs, the first time and every time after: the AI declares each resource in .decree/lib/infra/requirements (command python3, pip hypothesis 6.112.0), and a script installs what is missing and checks every line, as Ansible applies a play. Nothing runs sudo; a step only you can do prints the command and fails until you run it. develop checks the same file before it starts (Declared environment).
qa tries to break the project and hands what breaks to develop. The AI lists the attack surface, writes chaos tests in tests/chaos/ against the invariants in .decree/lib/qa/invariants.md, using the wrenches in lib/qa/wrenches.md (bad input, repetition, interruption, full disks, corrupt state, …), and runs them. Triage marks each real bug's tests expected to fail and writes one report per root cause, stating the broken invariant and the expected behaviour and proposing no fix, and each report becomes a develop message. QA never fixes code: a script fails the step if the AI changed anything outside the chaos tests. Set QA_RUN in lib/qa/qa.sh to the command that runs the chaos suite first, and add it to the gate once qa finds bugs, so a fixed bug whose test is still marked fails the gate (D62).
2. Write a script. A script does one thing. Exit 0 is the event done, anything else is error.
cat > .decree/scripts/greet.sh <<'EOF'
#!/usr/bin/env bash
echo "Hello from $DECREE_MACHINE: $(sed -n 's/^# //p' "$DECREE_MESSAGE" | head -n 1)"
EOF
cat > .decree/scripts/ask_person.sh <<'EOF'
#!/usr/bin/env bash
echo "$DECREE_QUESTION Reply with: decree event $DECREE_WAIT_ID <event>"
EOF
chmod +x .decree/scripts/greet.sh .decree/scripts/ask_person.sh3. Write a machine. It names scripts, never paths: greet resolves to scripts/hello/greet*, else scripts/greet*. Editors that read SchemaStore apply the machine schema by path, so they complete keys and underline mistakes (editors).
cat > .decree/machines/hello.yml <<'EOF'
name: hello
description: Greet, ask a person to approve, then finish.
initial: greet
states:
greet:
invoke: greet
transitions: { done: approval }
approval:
invoke:
person:
question: Approve the greeting?
ask: ask_person
transitions:
approve: { target: done, description: Keep it. }
reject: { target: rejected, description: Throw it away. }
done: { final: true }
rejected: { final: true }
failed: { final: true }
EOF4. Write a message. Migrations are messages that run once, in filename order. A migration's run gets a time id like every other run; wherever you name a run, the migration's file name or stem (01-hello) works too.
cat > .decree/migrations/01-hello.md <<'EOF'
---
machine: hello
---
# Say hello
EOF5. Check, graph and run it.
decree check
decree graph
decree processdecree check validates every machine and pending message. decree graph writes a Mermaid diagram per machine to .decree/graph/. decree process runs greet, then pauses in approval and prints the decree event commands that answer it.
6. Reply, and look at the record.
decree event 01-hello approve
decree process
decree status
decree status 01-helloThe reply is a message too: decree event writes it to inbox/, and the next process delivers it. decree status 01-hello shows the run's events: transitions, scripts with durations, the wait and the reply. 01-hello.md is now in .decree/processed.md, so it never runs again.
7. Queue more work. Any script (or you) can queue a message with decree emit; the body comes from stdin, or a whole message (decree emit < .decree/migrations/01-a.md runs one again).
echo "# Greet again" | decree emit --machine hello
decree processA message is a markdown file with YAML frontmatter. decree reads and writes only the frontmatter and never changes the body.
---
machine: develop # machines/develop.yml; required
params: # sets the machine's data for this run
max_rounds: 3
---
# Add rate limiting to /api/upload
## Overview
## Outcome
## Constraints
## Acceptance CriteriaMessages come from four places, and all of them run the same way:
| Source | Where | trigger |
|---|---|---|
| Migrations | .decree/migrations/*.md, committed, never edited. Run once, in filename order; the ledger is processed.md. A failed one blocks the ones after it until decree process --retry. |
migration |
| Inbox | .decree/inbox/*.md, run first-in, first-out by filename. Write a file directly, or use decree emit. |
inbox, emit |
| Cron | .decree/cron/*.md templates with a cron: expression, queued by decree daemon. |
cron |
| Sub-machines | A state with invoke: { machine: <name> } starts a child run. |
invoke |
When decree claims a message it moves it to .decree/runs/<id>/message.md, adds id and trigger if they are missing, and mirrors the run's current state into it. runs/<id>/events.jsonl is the record: the run's state is always the last transition in it.
A reply to a waiting run is a message with to: (the wait id or run id) and event:. decree event writes one, and so can any tool.
A machine is an SCXML statechart written in YAML, in .decree/machines/<name>.yml. Keys use SCXML's names: initial, states, transitions, target, type, onentry, onexit, invoke, data, final.
# Graph: ../graph/deploy.md
name: deploy
description: Build, ask a person to approve, then ship.
initial: build
states:
build:
invoke: build # scripts/deploy/build*, else scripts/build*
transitions: { done: approval }
approval:
invoke:
person:
question: Ship it?
ask: ask_person
transitions:
approve: { target: ship, description: Ship this build. }
reject: { target: rejected, description: Do not ship. }
ship:
invoke:
script: { name: ship, attempts: 2 } # re-run on a non-zero exit
transitions: { done: done }
done: { final: true }
rejected: { final: true }
failed: { final: true } # every machine has one; unhandled errors go hereEvery state does one thing: it invokes a function, and the function's result is the event that picks the next state. invoke has exactly one key, which names the kind:
invoke: |
What happens | Events |
|---|---|---|
<script>, or script: { name: <script>, attempts: 2, timeout: 10m } |
Runs the script, re-running it up to attempts times and stopping it after timeout (a duration: 90s, 10m, 12h, 7d). |
done, error, or the event it writes to $DECREE_EVENT_FILE |
check: <condition> |
A deterministic condition over a state's output, data, visits or a model's confidence, such as check: { visits: fix, less_than: 3 }. |
true, false |
model: { question: ..., output: <state> } |
A router machine asks a model to pick one of the state's transitions, with a confidence, given the output state's output and the message body. Below min_confidence the event is unsure. |
the transition names, unsure |
person: { question: ..., ask: <script> } |
The ask script tells someone; the run pauses until a reply arrives. |
the transition names, error on timeout |
machine: <name>, or machine: { name: <name>, params: {...} } |
Runs another machine as a child run. | the child's final state (failed as error) |
Other keys: onentry and onexit (scripts run on entering or leaving a state, or the whole run at the root), data (typed values set from a message's params), emits (machines a state's scripts may decree emit to), compound states (initial plus states, with transitions that bubble up) and final.
The reference is the full contract, the decision log says why, and examples/ holds worked projects with real files: start at examples/project/ for what a project's files look like. examples/newsletter/ is the simplest one that does real work: three scripts in a straight line, run by cron.
decree graph writes .decree/graph/<machine>.md for every machine and .decree/graph/system.md for how machines emit to and invoke each other. Commit them.
- Run
decree graph, then open.decree/graph/<machine>.md(orsystem.md). - In VS Code, press
Ctrl+Shift+V(Cmd+Shift+Von macOS) for the preview; VS Code 1.121 and later render Mermaid in Markdown without an extension. GitHub, GitLab and Obsidian render the committed files as they are. - Without any of those, copy the lines inside the
mermaidfence into https://mermaid.live.
decree has a JSON Schema for every file it reads or writes: machines, message frontmatter, events.jsonl lines, and a router's request.json and reply.json. They live in schema/v1/, published at https://raw.githubusercontent.com/jtmckay/decree/main/schema/v1/, and are versioned: within v1 changes are additive only (Versioning, changelog). Editors apply the machine schema to .decree/machines/*.yml through SchemaStore, or a setting until then, with no line in the file, so VS Code with Red Hat's YAML extension, JetBrains IDEs, or any editor running the YAML language server completes keys, shows what each one means and underlines mistakes as you type (editors). decree schema writes a local copy to .decree/schema/v1/, ignored by git, for agents and offline use: a model reads it as the contract to write machines against. The schema checks shape; decree check also checks that names resolve, targets exist and every state is reachable (Schema).
A model state asks a router: an ordinary machine that reads request.json, asks a model however it likes and writes reply.json. decree validates the reply against the state's transitions, so the model never names a state. A state names its router with router:; without one it uses the machine named router, which decree init writes to ask Claude, Copilot or OpenCode (scripts/router/ask_<ai>.sh). Routers are typed (a classifier such as GLiNER2.5-Decide, or a model held to the request's reply_schema, which cannot answer outside the options) or untyped (a chat model such as claude -p, whose free-text reply is checked after the fact): use typed routers for routing and untyped models for the work, as docs/routers.md explains.
A script is any executable file. decree runs it directly from the project root, so the shebang picks the language.
Script X used by machine M is the first match of X or X.<ext> in:
.decree/scripts/M/.decree/scripts/
An invoked script's event is error on a non-zero exit. On exit 0 it is done, unless the script writes an event name to the file $DECREE_EVENT_FILE (echo pass > "$DECREE_EVENT_FILE"). Output goes to runs/<id>/<NNNN>-<state>-<script>.log and is only a log: decree never reads an event from it. Scripts get the run's context in DECREE_* variables: DECREE_MESSAGE, DECREE_MACHINE, DECREE_STATE, DECREE_ATTEMPT, DECREE_DATA_<NAME> and more (decree help lists them all). A run's own files go in $DECREE_RUN_DIR; what a machine keeps between runs goes in $DECREE_STORE and is declared under the machine's store:, so the machine file says what it remembers; shared code and config go in $DECREE_LIB.
Model servers and other long-running processes are not scripts and decree does not manage them: an onentry script starts what a state needs. docs/services.md shows systemd units, llama-swap and a tmux layout for that.
An agent a script starts runs with your own configuration, because decree adds nothing between them. The built-in machines call plain claude -p (with --permission-mode, default auto, set by CLAUDE_PERMISSION_MODE, and --model when CLAUDE_MODEL is set), so your Claude Code settings, hook scripts and skills apply to every agent step. A token filter such as rtk, which shortens the output of the agent's own shell commands through a Claude Code hook, therefore needs no setup in decree: install its hook once and every run uses it. It does not filter files the agent reads, such as the gate.log that develop's fix step reads, and decree's own logs stay complete.
| Command | What it does |
|---|---|
decree init [--ai AI] [--permissions] |
Create .decree/ with machines, scripts, the router machine and the decree skill |
decree check [--format text|json|sarif] |
Validate machines and pending messages; sarif for code scanning |
decree graph |
Write Mermaid diagrams to .decree/graph/ |
decree schema |
Write a local copy of the JSON Schemas for machines, messages, events, router files and --format json output to .decree/schema/v1/ |
decree skill [--ai AI] |
Refresh the decree skill in .claude/skills/decree/ or .github/skills/decree/ after an upgrade |
decree process [--dry-run] [--machine M | --exclude-machine M] |
Deliver replies, continue pending runs, drain inbox/, then run pending migrations in order; --machine runs only those machines, --exclude-machine all but those, so several processes can share one folder |
decree process --retry [ID] [--state S] |
First continue a failed or interrupted run: ID, or the migration blocking the queue |
decree daemon [--interval D] [--retry [ID]] [--machine M | --exclude-machine M] |
The same passes plus cron, every D (default 2s), until stopped: what unattended work runs under, since process exits while a run waits for a person |
decree emit [--machine M] [--param K=V]... |
Queue a message from stdin, a body for M or a whole message; prints its id |
decree event ID EVENT [-m NOTE] |
Reply to a run waiting for a person |
decree stop [-m NOTE] |
Let the runs going now finish, then hold the queue until .decree/attention/manual-stop.md is deleted; Ctrl-C interrupts the run instead |
decree status [ID] |
What needs attention, runs by status and queued messages; one run's events |
decree status --cron |
Cron files and when each fires next |
decree tail [ID] |
Follow the live output of a run |
decree prune --older-than AGE [--dry-run] |
Delete finished runs older than AGE (30d, 12h, 90m, 90s); ship them to Loki first if you keep history |
decree upgrade |
Print how to upgrade decree: the install line, pinned to a release, and for a cargo install copy; then decree skill (and decree schema) in each project. It runs nothing itself |
decree help |
Full reference: files, keys, environment variables |
decree |
Print a synopsis of the quick start; it runs nothing (decree process runs the queue) |
decree --version |
Print the version |
--format json on check, status, emit, event, stop, prune, graph, schema, skill, upgrade and process --dry-run prints one JSON document, described by schema/v1/cli/<command>.schema.json, for CI and agents; exit codes do not change. decree check --format sarif prints a SARIF 2.1.0 log for GitHub code scanning, GitLab or Azure DevOps (Machine-readable output has a GitHub Actions job).
decree never continues a run that was stopped by a signal or a crash: the run is interrupted until you run decree process --retry, because a kill may be deliberate. A failed migration blocks the ones after it the same way, and decree process prints the exact command that continues it.
.decree/
.gitignore # inbox/, runs/, store/, attention/, .env and other local files
.env # KEY=value for every script; the process environment wins
machines/<name>.yml # machines
scripts/<name> # scripts shared by every machine
scripts/<machine>/<name> # a machine's own scripts, found first
lib/ # code, config and data scripts source ($DECREE_LIB); never run by decree
migrations/ # ordered, run-once messages; committed, never edited
processed.md # ledger of migrations that ran; committed
inbox/ # queued messages
cron/ # message templates queued on a schedule
runs/<id>/ # one folder per run: message.md, events.jsonl, traces.jsonl, logs
attention/<id>.md # what needs a person, with the command that continues it
store/<machine>/ # what a machine keeps between runs ($DECREE_STORE), declared under its store:; never pruned
graph/ # written by decree graph; committed
schema/v1/ # optional local copy written by decree schema; not committed
There is no configuration file. Each setting is a convention, a fixed limit or a variable:
| Setting | Instead |
|---|---|
Router for a model invoke |
The machine named router, unless the invoke sets router: |
| Default machine | None: every message names its machine: |
| Retries | attempts in the script invoke, a number or a list of values ([local, claude]); default 1 (no retry) |
| Emit depth | 10, unless DECREE_MAX_DEPTH in the shell or .decree/.env sets it |
| Log size | Each script log is capped at 2 MiB |
| Sharing across projects | Symlink shared machines and scripts into machines/ and scripts/ |
A cron file (.decree/cron/<name>.md, queued by decree daemon):
---
cron: "0 9 * * 1-5"
machine: develop
---
# The weekday morning task
## Overview
## Outcome
## Constraints
## Acceptance Criteriaevents.jsonl is one JSON line per event, with run_id, machine and type on each, so any log shipper can read it. Each run is also an OpenTelemetry trace: spans for the run, its scripts, decisions and child runs go to traces.jsonl as OTLP/JSON, scripts get TRACEPARENT so their own instrumented calls join the trace, and a message's traceparent continues an upstream one (Traces). examples/observability/ ships the events to Loki with Grafana Alloy, with example queries for Grafana, and the traces to Jaeger with the OpenTelemetry Collector.
decree runs scripts and Claude with your permissions and no sandbox; read the security policy before processing messages you did not write.