Authoring a recipe
Authoring a recipe
A recipe is a JSON file in .mentu/recipes/. Prompts live next to it in
.mentu/prompts/. mentu-recipes init creates both directories, empty.
mentu-recipes initInitialized .mentu/recipes and .mentu/promptsRecipes resolve by name from <workspace>/.mentu/recipes and then
~/.mentu/recipes. You can also pass a path, but it has to land inside one of
those two roots after symlinks are resolved, or the runner reports
Recipe not found. Prompt files resolve the same way from the matching
prompts directories. Prompt paths must be relative: absolute paths, ~
paths, and paths that traverse out of the prompt root are rejected.
A step declares three things
{
"label": "edit",
"backend": "claude",
"prompt_file": "PROMPT-tidy.md",
"completion_keyword": "TIDY_COMPLETE",
"expected_changes": ["CHANGELOG.md"],
"timeout": 300
}That step names the claude backend, so it needs a provider on the machine.
claude and codex run an agent CLI, which has to be installed and already
authenticated. openai, openai-chat, and deepseek call an HTTP API and
refuse to start without a key in the environment or the vault. ollama calls
a local HTTP server and needs no key. A shell step needs neither. See
Credentials.
completion_keyword is the completion signal. The step is complete only if
the process exits zero and the keyword appears in its stdout or stderr. A
non-zero exit fails the step even when the keyword was printed. For a shell
step with no keyword, exit code zero is the signal on its own.
expected_changes is the write boundary. It takes repo-relative paths and
globs (Sources/, Tests/**/*.swift, README.md). When the step closes inside
a git worktree, matching dirty paths are staged and committed. Anything else the
step dirtied is written to a quarantine patch under the run directory and left
out of the commit.
The runner compares the workspace against a pre-step baseline, so files that were already dirty before the step started are recorded as pre-existing and are not blamed on the step.
verify is the proof. It runs after the step closes and asserts local file
state without a model in the loop.
What the boundary does in practice
A step that writes one declared file and one undeclared file commits the first and quarantines the second:
✓ 1/1 write shell 0s ok · Quarantined changes outside expected_changes: notes/scratch.md
✓ stray · 1 step(s) · okgit show --stat --oneline HEAD75d6d98 chore: mentu-recipes step write (run_20260902181946_6CC22941)
notes/hello2.md | 1 +
1 file changed, 1 insertion(+)The undeclared write is not lost. It is in the run directory as a patch you can read and apply:
.mentu/runs/run_20260902181946_6CC22941/quarantine/run_20260902181946_6CC22941-write-quarantine.patchThe commit is what makes the step count as ok. A step that declares
expected_changes, touches none of the declared paths, and writes somewhere
else has nothing to commit, so the runner quarantines the stray write and marks
the step failed:
⏵ 1/1 write shell 0s running
OK
✗ 1/1 write shell 0s failed · exit 0
mentu-recipes: Recipe failed
✗ only-stray · 1 step(s) · failedA step that declares expected_changes and changes nothing at all is still ok,
with a bookkeeping note:
✓ 1/1 idle shell 0s ok · no changes matched expected_changesThe verification kinds
{
"verify": {
"grep_present": [
{ "file": "README.md", "pattern": "Mentu Recipes", "min": 1 }
],
"grep_absent": [
{ "file": "CHANGELOG.md", "pattern": "TODO", "description": "the changelog must not ship with TODO markers" }
],
"file_absent": [{ "file": ".env" }],
"git_clean_outside": ["Sources/", "README.md"],
"commands": ["swift test"]
}
}pattern is matched as literal text, not as a regular expression.
They do not all carry the same weight, and the difference matters before you rely on one:
| Check | On failure |
|---|---|
completion_keyword absent from output, or non-zero exit |
step fails |
verify.commands non-zero exit |
step fails |
verify.file_absent matches |
step fails |
verify.git_clean_outside violated |
step fails |
verify.grep_present / grep_absent unmet, or file missing |
step is marked warn_bookkeeping, run continues |
write outside expected_changes, with a declared path also written |
change is quarantined, step is marked warn_bookkeeping |
write outside expected_changes, with no declared path written |
change is quarantined, step fails |
An unmet grep assertion prints its description and keeps going:
✓ 1/1 prove shell 0s ok · intentionally unmetA failed verify.commands stops the run:
✗ 1/1 prove shell 0s failed · exit 0
mentu-recipes: Recipe failed
✗ gate · 1 step(s) · failedNote the exit code in that transcript. The command the step ran succeeded. The
assertion in verify.commands is what failed. If an assertion has to stop a
run, that is where it belongs.
git_clean_outside declares a boundary without committing anything, which is
what concurrent steps should use. Verification paths are relative to the step
directory and cannot escape it. Verification commands run through /bin/sh
from the step directory with a 300 second timeout.
Check before you run
mentu-recipes check my-recipe
mentu-recipes doctor my-recipe --strictcheck loads and validates the file. It refuses structural mistakes outright:
mentu-recipes: Invalid recipe: step 'join' depends on unknown step 'nope'doctor is the quality pass. It scores the recipe out of 100, subtracting 30
per error, 10 per warning, and 2 per info finding, and names what is missing:
# Mentu Recipes Doctor
- Recipe: `loose`
- Score: 80
| Severity | Code | Location | Recommendation |
| --- | --- | --- | --- |
| warning | missing_completion_signal | steps[0].edit | Add `completion_keyword` or `verify` so completion is observable. |
| warning | missing_expected_changes | steps[0].edit | Declare expected paths or add `verify.git_clean_outside`. |Findings you will meet:
| Code | Severity | Meaning |
|---|---|---|
missing_completion_signal |
warning | A non-shell step has neither completion_keyword nor verify. |
missing_expected_changes |
warning | A step that looks like it writes has neither expected_changes nor verify.git_clean_outside. |
unknown_backend |
error | The backend is neither built in nor defined under providers. |
duplicate_step |
error | Two steps share a label. |
generic_provider_credential |
error | A custom provider claims the built-in OpenAI or DeepSeek credential name. |
destructive_shell |
error | A shell command matches a known destructive pattern. |
unsupported_allowed_tools, unsupported_disallowed_tools, unsupported_thinking |
warning | The step sets an option its backend ignores. |
default_timeout |
info | A provider step relies on the 1800 second default. |
doctor inspects steps only for sequence and formula recipes. An error
finding exits non-zero on its own. --strict extends that to warnings, which
is what you want in CI. A clean recipe looks like this:
# Mentu Recipes Doctor
- Recipe: `hello-notes`
- Score: 100
No findings.Prompts
Use prompt for an inline string, or prompt_file for a file in
.mentu/prompts/. For a shell step the prompt is the command itself.
Before a prompt is sent, ${NAME} and $NAME are replaced with the value of
NAME. A --var NAME=value flag wins over the step env block, which wins
over the recipe env block, which wins over the process environment. The same
substitution applies to env values themselves.
Treat recipes as code
A recipe can call external providers and, with a shell step, run local
commands. Review third-party recipes before you run them, and keep destructive
commands out of shared examples. doctor flags shell commands that match known
destructive patterns, but it is a lint, not a sandbox.
Next
- Recipe schema: every field the runner reads.
- Patterns: sequence, fan-out, and compound shapes.
- Credentials: what each backend needs.