You cannot truly respect a guardrail until you’ve seen what it stops. So: Juniper is green, committed, safe — and we are now going to break it seven ways, on purpose, and watch what happens each time.
Work on a branch so the wreckage is disposable:
git checkout -b wreck-it
For each break below: make the edit, run
python3 scripts/lint_mantle.py, read the message, undo
(git checkout -- <file>), next. The whole tour takes fifteen minutes and
pays for itself the first time any of these happens to you for real.
Break #1 — indent the rules (the 39-rules bug)
In agent.yml, move rules: inside the agent: block — just indent it
to match persona:. Now ask yourself what the engine will do.
Answer: nothing. It parses fine. It would train fine. Juniper would run, minus its rules, and nothing anywhere would tell you. This is the exact shape of the bug that shipped 39 dead rules across a real catalog.
The lint is the only alarm that rings:
[agent-top-level-keys] agent.yml:8: 'rules' is nested inside 'agent:',
where the engine parses it and then silently discards it. Move it to
the top level of agent.yml, as a sibling of 'agent:'.
One more twist worth knowing: the check derives the indentation from your file rather than assuming two spaces — so reformatting to four-space YAML doesn’t create a blind spot. Defensive checkers have to out-stubborn formatters.
Break #2 — configure the LLM the way the internet tells you to
Replace the llm: block in integrations.yml with the pre-2026 form you’ll
find in most blog posts:
llm:
provider: openai
model: gpt-4
[llm-model-group] integrations.yml:2: 'provider' is set inline under 'llm:'.
Since 3.20.0.dev6 the orchestrator LLM is a model-group reference: use
'llm: {model_group: <id>}' and declare the provider under a matching
'model_groups' entry.
[llm-model-group] integrations.yml:1: 'llm:' does not name a model_group;
validate will reject it with "'model_group': Field required"
This one isn’t silent forever — rasa validate would eventually reject it
with 'provider': Extra inputs are not permitted, a message that names
neither the file, the cause, nor the fix. The lint’s job here is to convert
a confusing failure later into a self-explaining one now.
Break #3 — let the LLM write project memory
In root memory.yml, add llm_settable: true to preferred_room.
[project-memory-writes] memory.yml:4: project memory cannot be
llm_settable — the engine rejects it. Have a tool write the field, or
move it into skills/<id>/memory.yml.
The rule behind the rule: project memory is shared state that other skills trust. If the model could write it directly, a hallucination becomes a fact every skill believes. Tools write project memory; the model writes its own skill’s scratchpad. The engine enforces the boundary — the lint just tells you before the engine’s less helpful version of this message does.
Break #4 — indent an if:
In skills/recommend_plant/skill.md, indent one of the if: lines by two
spaces. It still looks like logic. It is now prose — the LLM receives
the literal text “if: session.recommend_plant.room_type” as part of its
instructions, and your branch never branches. No error. Ever.
[nested-if] skills/recommend_plant/skill.md:9: indented 'if:' is not
parsed as a condition; it stays instruction prose. Move the branch to
the top level of the skill body, or express it in natural language.
Break #5 — reference memory the wrong way in prose
In the same file’s prose, write Tell the customer their room is session.recommend_plant.room_type and greet them with @memory.name.
[skill-prose] skills/recommend_plant/skill.md:12:
session.recommend_plant.room_type appears in instruction prose; it is
not substituted there. Use @memory.recommend_plant.room_type or move
it into a top-level 'if:'.
[skill-prose] skills/recommend_plant/skill.md:12: '@memory.name' is not a
substitutable token; live values require @memory.<namespace>.<entry>
Without the lint, your customer sees the literal text “session.recommend_plant.room_type” in a chat message. Which, to be fair, is how some of us first learned this rule.
Break #6 — “upgrade” to the newest stable Rasa
Change the pin to rasa-pro==3.19.1 — the newest stable release, so it
must be newer and better, right?
[engine-version-pin] pyproject.toml: rasa-pro==3.19.1 is a stable pin — # rasa-version-ignore: quoted lint output
stable releases ship no Mantle engine (verified against published
wheels). Pin a 3.20.0.dev release.
This is the single most counter-intuitive fact in the ecosystem, so here it
is plainly: the Mantle engine currently exists only in pre-release
(“dev”) versions. The newest stable rasa-pro on PyPI contains neither
rasa.mantle nor its predecessor — the claim was verified by inspecting
the actual published wheels, and the check’s docstring says to delete it
the day a stable release finally ships the engine. Until then, “dev” is
not the risky choice. It is the only choice.
While you’re in this file: lower requires-python to >=3.10 and run
uv lock. You’ll get an error about “versions that are not supported by
your dependencies” that never mentions Python once. The lint names it:
[engine-version-pin] pyproject.toml: requires-python floor 3.10 is below
3.11; rasa-pro 3.20 needs >=3.11 and uv's resolver error will not
mention Python
Break #7 — paste a key where it doesn’t belong
Put a fake key in your README: sk- followed by twenty-odd characters.
Then try to commit it:
[secret-hygiene] README.md:3: looks like a committed OpenAI-style key
COMMIT BLOCKED by lint_mantle.py (exit 1).
The hook catches it at the last moment before it enters git history — which
matters, because history is forever: a key committed once is compromised
even if you delete it in the next commit. This scan runs on every commit
for every file, which is why “where do I put my key?” has a one-word
answer: .env, and only .env.
Clean up, and what you now know
git checkout main # or: git checkout -- . on the branch
git branch -D wreck-it
Seven breaks, seven catches — and the important part is the taxonomy:
- Breaks 1, 4, 5 are silent at runtime. The lint is their only alarm.
- Breaks 2, 3, 6 fail eventually, with messages that don’t name the cause. The lint converts them into instant, self-explaining findings.
- Break 7 is the irreversible one. The hook makes it nearly impossible to do by accident.
Every one of these messages told you the file, the line, the why, and the
fix. That’s the standard the pack holds itself to — inherited from the
catalog’s own checker, whose source
(lint_repo.py)
reads like a well-kept incident log. Yours
(lint_mantle.py)
is its portable descendant.
Juniper survived the crash tests. Let’s make it talk.
