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 (k8s by default), framework hint (auto infers), and observability switches (with_cos=true, with_tracing=true). Run /recipe charm-new --help for 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 /metrics on port 8080; override per workload with metrics_path=... and metrics_port=.... Retries up to twice until the charm packs cleanly and make check is green.
/recipe charm-reactive-to-ops
Migrate a reactive charm (charms.reactive / @when_* decorators / layer:foo stack) onto the Operator Framework idiom: ops.CharmBase with 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:

  1. Bundled. Ships with Cantrip itself (charm-new, charm-cos-add, charm-reactive-to-ops).
  2. ~/.config/cantrip/recipes/*.yaml — your personal recipes, available across every charm.
  3. <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:

  1. Binds since_tag=v1.4.0 and include_chores=true to the declared parameter types (string, boolean), rejecting any unknown key.
  2. Renders the Jinja-templated instructions with the bound parameters in scope. The {% if include_chores %} branch fires.
  3. Hands the rendered prompt to the agent's primary conversation loop and waits for a reply.
  4. Validates the final assistant text against the check_result built-in schema. A schema mismatch triggers one corrective retry (max_retries: 1).
  5. 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 /recipe and /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) and StrictUndefined raises 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_-]*), a type (string, number, boolean, date, file, or select), an optional requirement (required, optional, or prompted), optional default and description, and (for select) 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) or json_schema (an inline JSON Schema mapping). The recipe's reply is validated post-hoc; pair with a json_schema check inside retry to 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 optional on_failure shell 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_turns all parse cleanly today; a follow-up landing wires them into the conversation loop). The recipe's --help output 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 optional values mapping bound directly to the sub-recipe's declared parameters (no argv re-parsing), and an optional sequential_when_repeated flag. 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, use boolean.
boolean
Accepts true / yes / 1 / on and 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 via str() so YAML options: [1, 2, 3] and CLI size=2 both 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.