Mentu

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 init
Initialized .mentu/recipes and .mentu/prompts

Recipes 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) · ok
git show --stat --oneline HEAD
75d6d98 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.patch

The 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) · failed

A 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_changes

The 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 unmet

A failed verify.commands stops the run:

✗ 1/1    prove                   shell            0s  failed · exit 0
mentu-recipes: Recipe failed
 
✗ gate · 1 step(s) · failed

Note 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 --strict

check 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

© 2026 Mentu.