Skip to main content

Writing a Routine

Every automation in this stack is the same two things: a routine (a bash script that does one job) and a trigger (something that decides when to run it). Nothing else. Once you can write one and pick the other, the rest of this section is detail.

This page is the contract. If you want to watch it used end to end on a real problem, read Note → Action alongside it — that flow is four routines wired together and nothing more.

The shape of every message​

Before the script, the thing the script receives. Decree has exactly one message format, and every trigger produces it:

---
routine: note-triage
TRIAGE_CRITERIA: "a business idea"
---

Anything down here is the body.

routine: is the only required key. Every other frontmatter key becomes an environment variable for the routine, and the body is available at ${message_file}. A cron file, a webhook POST, a message another routine wrote, a file you drop in the inbox by hand — they all end up as this. Learn this one shape and every trigger below is the same trigger.

Anatomy of a routine​

Routines live in automation/shared_routines/<name>.sh. The filename minus .sh is the routine name. Here is the whole structure, in order — service-health.sh is a good short one to read next to this:

#!/usr/bin/env bash
# my-routine — one line on what it does.
#
# Everything in this header block is the documentation. `decree routine
# my-routine` prints it, so write it for the person who has to enable this in
# six months. Say what it needs, what it writes, and how to run it by hand.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

# Decree sets these for every run.
message_file="${message_file:-}" # the message that triggered this run
message_id="${message_id:-}" # e.g. D0001-1432-my-routine-0
message_dir="${message_dir:-}" # run directory, holds logs from prior attempts
chain="${chain:-}" # chain ID, if this run was chained
seq="${seq:-}" # position in that chain

# The pre-check. Required. This is the routine's self-test.
if [ "${DECREE_PRE_CHECK:-}" = "true" ]; then
source "${SCRIPT_DIR}/../lib/precheck.sh"
command -v jq >/dev/null 2>&1 || precheck_fail "my-routine" "jq not found"
precheck_pass "my-routine"
exit 0
fi

# Your settings. This block is special — see below.
my_param="${my_param:-a default}"
MY_LIMIT="${MY_LIMIT:-20}"

# --- Implementation ---

echo "Doing the thing with ${my_param}"

Four things in there are load-bearing.

The header comment is the documentation​

There is no separate doc for a routine. decree routine <name> prints this block, and it is what someone reads before enabling your script. Write down the non-obvious parts: what has to exist first, what it writes and where, and the failure that will confuse them.

set -euo pipefail, always​

Decree treats a non-zero exit as failure and retries the message up to max_attempts, then dead-letters it. That retry is only useful if your script actually fails when a step fails.

Exiting non-zero is also how your routine gets you a notification. Dead-lettering fires the onDeadLetter hook, which sends an ntfy alert naming the routine, the message, and the last few log lines — for every routine, with nothing to wire up. Repeats are rate-limited to one alert per routine per 6 hours (DECREE_ALERT_COOLDOWN_MIN), and a routine that succeeds again resets that, so the next failure alerts immediately. A routine that swallows its own errors and exits 0 opts out of all of this.

The pre-check gate is your self-test​

Gate it on DECREE_PRE_CHECK=true, put it after the standard variables and before your own settings, and exit non-zero with a message naming what is missing. precheck_fail in automation/lib/precheck.sh formats it consistently.

This is what docker exec automation decree routine <name> runs, and it is the fastest way to find out why an automation is quietly doing nothing. Check for the things that are actually absent in a fresh install — a missing binary, an unset key, a directory the upstream routine was supposed to create — and say in the message how to fix it, not just what is wrong:

[ -d "${NOTES_DIR:-/data/notes}" ] || precheck_fail "note-triage" \
"NOTES_DIR ${NOTES_DIR:-/data/notes} does not exist — run the 'notes' routine first, or point NOTES_DIR at your vault"

Settings must be one unbroken block​

Decree discovers your routine's parameters by scanning the file top to bottom and matching the exact form name="${name:-default}". It stops at the first line that does not match after the pre-check block.

So this works:

FOO="${FOO:-1}"
BAR="${BAR:-2}"

and this silently loses BAR:

FOO="${FOO:-1}"
echo "starting" # ← scanning stops here
BAR="${BAR:-2}" # ← never settable from frontmatter

Put every setting in one run of lines directly after the pre-check, then start the implementation. ${VAR:-} with an empty default is fine — it means optional, no default.

Registering it​

A routine on disk is invisible until it is listed. Every daemon has a <category>/<slug>/decree/config.exist.yml with a shared_routines: whitelist:

shared_routines:
my-routine:
enabled: true
  • Unlisted means invisible. Not disabled — absent.
  • enabled: false means the routine is known and off. That is the right default for anything opt-in; the user flips it on.
  • Add it to every daemon that should see it — there are two, and each has its own config.
  • config.exist.yml is the tracked template. ./existential.sh renders it once to config.yml, which is gitignored and is where the user's own overrides live — so edit the template and, if config.yml already exists, the rendered copy too.
Which daemon?

There are two and they are not interchangeable: automation (project dir services/automation/decree/) and automation-backup (services/automation/backup/). Neither container is called decree — that's the binary inside them.

automation is where anything that reasons, routes, calls a service API, or runs a service's one-time migrations belongs. It alone gets the hermes gateway credential — the other daemon has no way to reach a model at all.

automation-backup is where anything that needs bulk volume data or every credential at once belongs: it alone mounts volumes/ wholesale and takes the master .env. Despite the name it is not backups-only — workspace-sync lives there for exactly that access, not because it backs anything up. What it must never do is reason, route, or make an AI call; that stays in automation even when it touches the same data.

Both have a writable /workspace. A routine that wants an AI and bulk volume access is a routine that should be two.

Choosing a trigger​

Four ways in. All of them produce the same message.

Cron — on a schedule​

Add a file to automation-examples/cron/<name>.md (tracked), then copy it into automation/cron/ (active, gitignored, mounted into the daemon) and restart it:

---
cron: "0 * * * *"
routine: my-routine
my_param: "something"
---

What this schedule is for. The body is free text.
cp automation-examples/cron/my-routine.md automation/cron/
docker compose restart automation

The copy-to-activate split is deliberate: examples ship for everyone, and nothing runs on your machine until you put it in cron/. Frontmatter is parsed on restart, so changing a setting means restarting the daemon.

Webhook — when something out there happens​

automation-webhook turns an HTTP POST into an inbox message. Endpoints are configuration only — add one to services/automation/webhook/config.exist.yml, re-render, restart. Each carries its own bearer token. This is how Home Assistant, a phone shortcut, or any external service reaches the stack. See Build On It for the request contract.

An object landing in storage — when a file shows up​

SeaweedFS fires an event on write → the webhook → s3-router → your file processor. A processor is a small script in automation/lib/file-processors/ that declares what it matches:

PATTERN="nextcloud:S3/workspace/.*\.md$"
CRITERIA="an open question the author has not resolved"

PATTERN is a path regex; the optional CRITERIA adds a one-call model gate so you match on content rather than filename. No restart needed — the router reads the directory per event. Full detail in File Change Processing.

By hand — for testing, and for one-offs​

Decree has no run subcommand — the only way to trigger a routine manually is dropping a message straight into the inbox it drains. For the main daemon that's the repo-root automation/inbox/ (it wholesale-mounts automation/ as its whole project); for automation-backup it's services/automation/backup/inbox/.

printf -- '---\nroutine: my-routine\nmy_param: value\n---\n' > automation/inbox/once.md

Chaining: one routine handing work to the next​

This is what turns routines into flows. A routine does not call another routine — it writes a message and returns. Decree picks it up and runs it as its own job, with its own retries, its own log and its own line in the dashboard.

OUTBOX_DIR="${OUTBOX_DIR:-/work/.decree/outbox}"
mkdir -p "$OUTBOX_DIR"

cat > "${OUTBOX_DIR}/handoff-$(date +%s%N).md" <<MSG
---
routine: hermes-dept
profile: research
output_name: competition
---
Who else is already doing this?
MSG

Three rules:

  • Write to the outbox, never the inbox. Decree relays outbox → inbox itself. Writing straight to the inbox races the daemon.
  • mkdir -p "$OUTBOX_DIR" first. Decree reads the outbox but never creates it, and an absent directory is silently treated as "no messages" — your handoff disappears with no error.
  • Unique filenames. $(date +%s%N) is what the shipped routines use. Two messages written in the same second must not collide.

Fan-out is just writing more than one message. Chains are bounded by max_depth (100), so a routine that queues itself will stop rather than run forever.

Verifying it​

# 1. The pre-check — does it think it can run?
docker exec automation decree routine my-routine

# 2. Run it for real
printf -- '---\nroutine: my-routine\n---\n' > automation/inbox/once.md

# 3. Read what happened
ls automation/runs/ # one directory per message
cat automation/runs/<id>/run.json # status, timing, exit code
cat automation/runs/<id>/routine.log # full output, written live

Every run also ships metrics to Prometheus and its log to Loki automatically — the afterEach hook does it, so a new routine shows up in Grafana's Decree Overview dashboard with no wiring on your part.

Common reasons a routine appears to do nothing:

SymptomCause
No run directory at allNot listed in that daemon's config.yml, or enabled: false
Cron never firesFile is in cron.example/, not cron/ — or the daemon wasn't restarted
A frontmatter setting is ignoredIt is below a non-matching line in the settings block
A chained routine never runsWrote to the inbox instead of the outbox, or $OUTBOX_DIR didn't exist
Runs, exits 0, does nothingMissing set -euo pipefail, so a failed step was swallowed

Where to go next​