Define custom slash commands
Drop a markdown file into .cantrip/commands/ and the filename becomes a new slash verb.
The idea
Some prompts come up again and again — "triage this issue", "walk the relation between these two charms", "generate a release note from the last five commits". Typing the same 200 characters every time gets old fast. Custom slash commands let a charm team ship those prompts as ordinary markdown files, discoverable by every session that opens the charm.
Where Cantrip looks
Two locations, read on every agent startup; the repo wins when verbs collide:
~/.config/cantrip/commands/*.md— your personal commands, available across every charm.<charm>/.cantrip/commands/*.md— per-charm commands, committed alongside the code so every team member gets them automatically.
The filename becomes the slash verb: relation-check.md
registers /relation-check. Filenames must match
[a-z0-9][a-z0-9_-]*; anything else is rejected with
a clear error.
A working example
Save this as .cantrip/commands/relation-check.md in
a charm:
---
description: Inspect a relation on a deployed charm
agent: primary
---
Inspect the ``$1`` relation on my deployed charms. Current
relations according to Juju:
!`juju show-unit $1/0 2>&1 | head -80`
Walk through what the relation is carrying, whether the
endpoint is bound, and whether the remote side looks healthy.
Then, in any Cantrip session for that charm:
> /relation-check redis
Cantrip substitutes $1 with redis, runs
juju show-unit redis/0, folds the output into the
prompt, and hands the whole thing to the agent. The agent sees
a fully-hydrated message — same tools, same context, no
special case.
Frontmatter schema
---
description: <one-line summary; shown in /help and autocomplete>
agent: primary | research | build | deploy | test | debug | infra
model: <optional model override>
subtask: <optional bool; default false>
retry: <optional retry block; see "Retry blocks" below>
---
<markdown prompt template>
description- One-liner shown in
/helpand the autocomplete popup. Optional; Cantrip falls back to "User command from<filename>" if you omit it. agent- Which agent receives the expanded prompt.
primary(the default) means the conversation loop, exactly as if you had typed the expansion at the prompt. Any subagent category name (research,build,deploy,test,debug,infra) queues the expansion as a work-queue task and a subagent of that category picks it up. model- Override the model used for this command. Optional.
subtask- Force work-queue dispatch even when
agentisprimary. Useful when you want a command to run in the background without pausing the current conversation. retry- Optional declarative retry block. When set, Cantrip runs
the command, evaluates a list of checks against the
result, and retries with a corrective prompt if any check
fails. See Retry blocks below. Only
supported on
primarycommands today; settingretryalongsidesubtask: trueor a non-primary agent is rejected at load time.
Unknown frontmatter keys raise a clear error on load — no silent "typed a key wrong, command gets wrong defaults".
Placeholders
The body is plain markdown with four substitution shapes:
$ARGUMENTS— every token after the verb, verbatim.$1,$2, … — positional arguments, split withshlex. Unset positionals expand to the empty string.@path— contents of a repo-local file. Absolute paths and..traversals outside the repo root are refused.!`cmd`— stdout of a shell command, run in the repo root with a 10 s timeout and a 10 000-character cap. Every expansion flows through the permission policy — adenyrefuses the command and anaskparks on the CONFIRM surface.
Evaluation order is fixed: arguments first, then
@path, then !`cmd` ``. This means a
shell command can carry a filled-in positional argument, and a
file reference can include a path built from arguments. It also
means an earlier expansion cannot accidentally leak content that
gets re-expanded as a placeholder.
Interaction with permissions
Shell expansion runs through the same policy the subagent uses. That has two practical consequences:
- A command that tries to
!`rm -rf *`is denied by the built-in defaults; the user does not have to be trusted to resist typing dangerous custom commands. - A command that uses
!`git push origin main`pauses for approval by default (becausegit push *asks under the defaults). Authors can add a more specificallowrule if they want to skip the prompt.
Dispatch semantics
When you type a custom verb Cantrip:
- Looks up the command in the registry loaded at session start.
- Expands the body (arguments → files → shell).
- For
agent: primaryandsubtask: false— feeds the expansion toagent.process_message, exactly as if you'd typed it. The answer lands in chat. - For a subagent category or
subtask: true— creates a work-queue task of that category. Progress shows up in the task panel; the agent continues the foreground conversation unchanged.
The catalogue (/help, slash-command autocomplete,
the CLI startup banner) picks up every loaded command automatically.
No config entry required.
Retry blocks
A retry block declares a success predicate for the
command: a list of checks that must all pass before Cantrip
considers the run done. If any check fails, Cantrip re-runs the
command with a corrective prompt that quotes the failed checks
and the previous response. The block is the user-specified
counterpart to Ralph Loop — Ralph stops when the
agent says STOP; retry stops when
your shell command says yes.
---
description: Build the charm and pass unit tests
retry:
max_retries: 3
timeout_seconds: 600
checks:
- type: shell
command: "uv run pytest tests/unit -q"
- type: file_exists
path: src/charm.py
on_failure: "echo 'rolled back'"
---
Build the charm and run the full unit suite. Fix any failures.
max_retries- Number of retries on top of the initial attempt. Default
1; capped at50. The total attempt budget ismax_retries + 1. timeout_seconds- Total wall-clock budget across every attempt. Default
600(ten minutes). When the deadline is reached the runner returns without starting another attempt. checks- Ordered list of checks. Every check must pass for the run
to converge. Three types ship today:
shell— runscommandin the repo root and passes when the command exits with status 0. Routes through the permission policy:denyrecords a failure with the policy reason;askparks on the CONFIRM surface and refuses if the user declines. Optional per-checktimeout_secondsdefaults to 60 s.file_exists— passes whenpathresolves to an existing regular file. Path safety mirrors@path: absolute paths and traversals outside the repo root are rejected at load time.json_schema— validates the task's final response against the JSON Schema inschema. Markdown```jsonfences are stripped before parsing.
on_failure- Optional shell command run once if the run exits without converging. Useful for cleanup ("git stash drop", "rm -rf build"). Goes through the same permission gate as shell checks; a refused or failed launch is logged but does not raise.
Each retry attempt receives the original goal verbatim plus a short summary of which checks failed and an excerpt of the previous response. The original prompt is preserved so a long-context model does not drift toward the failure summary. The chat surface reports the attempt count and any unresolved failures in a one-paragraph summary at the bottom of the final response.
Troubleshooting
- "invalid command name" — filename must be lowercase, start with a letter/digit, and contain only letters, digits, hyphens, and underscores.
- "unknown frontmatter keys" — remove the extra key or rename it to one Cantrip knows about. The loader is strict by design.
- "refused by permissions policy" on
!`cmd`— add a bash glob to.cantrip/permissions.yamlthat allows (or asks about) the specific shape. - A malformed file is silently missing — check the agent log; the loader logs a warning per bad file rather than halting discovery.
- "retry" is not yet supported on subtask commands
— v1 wires retry into the primary
(
process_message) path only; removesubtask: true/ setagent: primaryor drop the retry block.