Run a recipe
Parameterised, retryable workflows committed alongside the charm — same prompt, same wiring, every time.
The idea
Cantrip already ships a handful of primitives for repeatable
work — skills for knowledge bundles,
flows for visual decision diagrams,
custom slash commands for one-off prompt
templates. Recipes fill the gap that opens when a custom command
starts growing: typed parameters with defaults, Jinja-rendered
instructions, post-hoc JSON-schema validation of the final reply,
and shell-validator-driven retry until make check
exits zero.
A recipe is a single YAML file. Drop one into
.cantrip-recipes/ next to your charm and
/recipe <name> key=value … becomes a
deterministic invocation that ships the same prompt, the same
parameter shapes, and the same convergence checks every time.
Bundled recipes
Three built-in recipes ship with Cantrip and are available in every session:
/recipe charm-new workload=<name>- Frame the research → design → confirm →
build pipeline for a new charm. Picks the substrate
(
k8sby default), framework hint (autoinfers), and observability switches (with_cos=true,with_tracing=true). Run/recipe charm-new --helpfor the full parameter list. /recipe charm-cos-add- Add Prometheus / Loki / Grafana / Tempo integrations to
the active charm using PyPI charm libraries. Defaults pick a
conventional
/metricson port8080; override per workload withmetrics_path=...andmetrics_port=.... Retries up to twice until the charm packs cleanly andmake checkis green. /recipe charm-reactive-to-ops- Migrate a reactive charm
(
charms.reactive/@when_*decorators /layer:foostack) onto the Operator Framework idiom:ops.CharmBasewith framework- observed events, Scenario unit tests, and Jubilant integration tests. Preserves action and relation interface names by default so already-deployed bundles keep relating.
Bare /recipe lists the catalogue;
/recipe <name> --help shows the parameter
list.
Where Cantrip looks
Three roots, in precedence order — later wins on name collision so you can override a built-in just by dropping a same-named YAML file into one of the writable scopes:
- Bundled. Ships with Cantrip itself
(
charm-new,charm-cos-add,charm-reactive-to-ops). ~/.config/cantrip/recipes/*.yaml— your personal recipes, available across every charm.<charm>/.cantrip-recipes/*.yaml— per-charm recipes, committed alongside the code so every team member gets them automatically.
The repo path is a sibling of the SQLite session file at
<charm>/.cantrip. Cantrip cannot use
<charm>/.cantrip/recipes/ because a single
path can't be both a regular file and a directory.
Both .yaml and .yml extensions are
recognised. Filenames must match
[a-z0-9][a-z0-9_-]*; the lowercased stem becomes
the recipe name. Malformed files log a warning and are skipped
so a single bad recipe never blocks the rest of the
catalogue.
A working example
Save this as
.cantrip-recipes/release-notes.yaml in a charm:
version: 1
title: Generate release notes from recent commits
description: |
Walk the commits since <since_tag> and produce release
notes grouped by feature / fix / chore. Output validates
against the <code>check_result</code> built-in schema so
downstream tooling can consume the JSON directly.
parameters:
- name: since_tag
type: string
requirement: required
description: Git tag (or sha) to start the changelog from.
- name: include_chores
type: boolean
default: false
description: Include <code>chore(...)</code> commits in the notes.
instructions: |
Read <code>git log {{ since_tag }}..HEAD --pretty=format:'%h %s'</code>
and produce structured release notes.
Group commits by Conventional Commit type: features under
<strong>Added</strong>, fixes under <strong>Fixed</strong>,
{% if include_chores %}chores under <strong>Chores</strong>,{% endif %}
refactors under <strong>Changed</strong>.
Reply with valid JSON conforming to the <code>check_result</code> schema:
<code>status</code> is <code>pass</code> if the changelog
parsed every commit, <code>fail</code> if any were unrecognised.
response:
schema_name: check_result
retry:
max_retries: 1
timeout_seconds: 120
Then, in any Cantrip session for that charm:
> /recipe release-notes since_tag=v1.4.0 include_chores=true
Cantrip:
- Binds
since_tag=v1.4.0andinclude_chores=trueto the declared parameter types (string,boolean), rejecting any unknown key. - Renders the Jinja-templated instructions with the bound
parameters in scope. The
{% if include_chores %}branch fires. - Hands the rendered prompt to the agent's primary conversation loop and waits for a reply.
- Validates the final assistant text against the
check_resultbuilt-in schema. A schema mismatch triggers one corrective retry (max_retries: 1). - Returns the final reply (validated) to the caller.
Schema
A recipe is a single YAML mapping at the file's top level. Unknown keys raise on load — typos surface immediately.
title— non-empty string- Catalogue label; rendered in
/recipeand/help. description— non-empty string- One-paragraph rationale; shown in
/recipe <name> --help. instructions— non-empty string- Jinja2 template body. Bound parameters appear in scope
under their names. Sandboxed (
__class__,__bases__, … are blocked) andStrictUndefinedraises on a missing parameter rather than silently producing empty text. String parameter values are scrubbed of{,}, and%before interpolation so a user-supplied value cannot smuggle template logic into the rendered prompt. parameters— list (optional)- Each parameter has a
name([a-z0-9][a-z0-9_-]*), atype(string,number,boolean,date,file, orselect), an optionalrequirement(required,optional, orprompted), optionaldefaultanddescription, and (forselect)options. response— mapping (optional)- Schema-validated final-output enforcement. Set exactly
one of
schema_name(a built-in —planner_briefing,oracle_answer,check_result,acceptance_report) orjson_schema(an inline JSON Schema mapping). The recipe's reply is validated post-hoc; pair with ajson_schemacheck insideretryto make Cantrip re-run until validation passes. retry— mapping (optional)- Declarative retry block. Same shape as a custom-command
retry block:
max_retries(default 1),timeout_seconds(default 600),checks(a list of{type: shell, command: ...}/{type: file_exists, path: ...}/{type: json_schema, schema: ...}entries; every check must pass for the recipe to converge), and an optionalon_failureshell command that runs once at the end if the run exits without converging. extensions— list of strings (optional)- Required tooling.
mcp:<server-name>declares an MCP server that must be CONNECTED in the active session;tool:<tool-name>declares a built-in tool that must be registered. Cantrip refuses to invoke the recipe when any extension is missing rather than sending the agent off to call tools that aren't there. settings— mapping (optional)- Recorded for forward-compatibility but not yet honoured
at dispatch (
model,temperature,max_turnsall parse cleanly today; a follow-up landing wires them into the conversation loop). The recipe's--helpoutput flags this so authors don't expect the override to apply silently. sub_recipes— list (optional)- Other recipes to invoke after the parent's primary reply.
Each entry has a
name, an optionalvaluesmapping bound directly to the sub-recipe's declared parameters (no argv re-parsing), and an optionalsequential_when_repeatedflag. Sub-recipes run sequentially in declaration order in v1; parallel/worktree dispatch is a follow-up. Cycles are refused with a clear message; missing sub-recipe names surface as a one-line "skipped" note rather than aborting the chain. version— string or integer (optional)- Defaults to
"1". Reserved for future schema migrations.
Parameter types
YAML scalars and CLI-style key=value strings both
run through the same coercer so a default declared as
default: 8080 in YAML and an argv override
port=8080 bind to the same Python value.
string- Accepts strings or stringifies scalars. Use
note="hello world"to pass quoted strings on the slash line. number- Parses floats. Booleans are rejected here even though
Python treats them as
int— if you want a flag, useboolean. boolean- Accepts
true/yes/1/onand the inverse set. Anything else raises rather than silently coerce. date- ISO 8601 (
YYYY-MM-DD); also accepts a YAML- native date literal in defaults. file- Passes the path string through verbatim. The recipe's instructions decide what to do with it — v1 does not validate existence here.
select- Must equal one of
options. Strings and numbers compare viastr()so YAMLoptions: [1, 2, 3]and CLIsize=2both bind.
Each parameter has a requirement: required
errors out when missing without a default; optional
binds None when absent; prompted defers
binding to a caller-supplied callback so an interactive surface
can ask the user (when no callback is wired, prompted
behaves identically to required).
Composition with retry and response
The most useful pattern for recipes that produce structured
output: declare both response and a
retry block with a json_schema check.
The retry block drives convergence (rerun until the JSON shape
matches) and the response block surfaces validation status
in the final reply.
response:
schema_name: acceptance_report
retry:
max_retries: 2
timeout_seconds: 600
checks:
- type: json_schema
schema:
type: object
properties:
overall_status:
type: string
enum: [pass, fail, partial]
required: [overall_status]
For recipes that just want a clean build, declare a
shell check that runs make check:
retry:
max_retries: 1
timeout_seconds: 1200
checks:
- type: shell
command: make check
timeout_seconds: 600
Shell checks gate through the same Phase 68.2 permission policy that the rest of Cantrip respects, so a denied command cannot be smuggled in via a recipe.
Related
- Define custom slash commands — lighter-weight prompt templates with positional / file / shell expansions.
- Add a custom skill — knowledge bundles the agent reads when context demands it.
- Response schemas
— details on the four built-in JSON schemas
(
planner_briefing,oracle_answer,check_result,acceptance_report).