Skip to content

tutorial

Chapter 2 of 4

Chapter 2 — Scaffold Juniper

by Rod Rivera Published

Generate the whole agent with one request to Claude Code, then walk every file it made — because "it works" is not the same as "I know why".

Open Claude Code in your juniper/ folder and say:

Use the mantle-new-project skill to scaffold a plant-shop helper called Juniper. Two skills: recommend a plant for a room, and check stock. Text only, no voice.

Claude Code reads mantle-new-project/SKILL.md — a playbook that contains the full, correct template for every file — and builds the project. Then it runs the lint, because the skill tells it to finish that way.

One request, working project. But you should never ship a file you couldn’t explain, so let’s walk through what appeared and why each file has the shape it has. Each shape is a decision, and most of the decisions were paid for.

pyproject.toml — three lines that are all trap-avoidance

requires-python = ">=3.11,<3.13"
dependencies = [
    "rasa-pro==3.20.0.dev6",
]

[tool.uv]
prerelease = "allow"
  • The pin says dev and that is correct. The Mantle engine ships only on the 3.20.0.dev pre-release line; the newest stable rasa-pro contains no engine package at all. This is the least intuitive fact in the whole ecosystem — Chapter 3 makes you feel it.
  • prerelease = "allow" — without it, uv refuses to resolve a dev pin.
  • The >=3.11 floor — Mantle 3.20 requires it, and the error you get with a lower floor doesn’t mention Python. (Also a Chapter 3 exhibit.)

lib/engine.py — one import to rule them all

try:  # 3.20.0.dev1 and later
    from rasa.mantle.tools.decorator import ToolContext, tool
    from rasa.mantle.tools.result import ToolResult
except ImportError:  # 3.19.x and earlier
    from rasa.calm_v2.tools.decorator import ToolContext, tool
    from rasa.calm_v2.tools.result import ToolResult

Rasa renamed the engine package (rasa.calm_v2rasa.mantle) in 3.20.0.dev1 — old name gone, not aliased. Every tool in your project imports from lib.engine, so the day the name changes again, you edit one file. This works because tool discovery keys off an attribute the @tool decorator sets, not the import path.

agent.yml — read the comment, it’s earning its keep

agent:
  id: juniper
  language: en
  persona: |
    You are Juniper, a friendly plant-shop helper.
    Ask one question at a time and keep answers short.
    Never invent stock numbers — always use tools.

# TOP-LEVEL keys, siblings of `agent:`. Nested inside they parse without
# error and are silently discarded.
name: Juniper
description: Plant-shop helper for recommendations and stock checks

rules:
  - 'Be warm, clear, and brief.'
  - 'Never state a stock count that did not come from a tool.'

The comment marks the site of the 39-ignored-rules failure from the series intro. persona lives inside agent:; name, description, and rules live outside it. Get that backwards and everything still parses, trains, and runs — minus its guardrails. This is silent trap #1, and the reason the scaffold writes the warning into the file itself.

integrations.yml — the LLM is a reference, not a config

llm:
  model_group: orchestrator

model_groups:
  - id: orchestrator
    models:
      - provider: openai
        model: gpt-4.1-mini
        api_key: ${OPENAI_API_KEY}
        temperature: 0.0

Older tutorials (most of the internet, honestly) put provider: and model: directly under llm:. Since 3.20.0.dev6 that form is rejectedllm: names a model group, and the provider details live on the group. Two practical notes:

  • ${OPENAI_API_KEY} is filled from your environment or .env at runtime — the name of the key is config, the value never is.
  • temperature: 0.0 because this model routes your conversation; routing wants determinism, not creativity.

memory.yml vs skills/*/memory.yml — who is allowed to write

Root memory.yml is project memory: facts every skill shares, written by tools only.

preferred_room:
  type: text
  description: The room the customer is shopping for.

Skill folders get their own memory.yml — and that’s where fields the LLM may set live, marked llm_settable: true. Putting that flag in the root file is rejected by the engine. The mental model: project memory is the database, skill memory is the scratchpad. The scaffold’s shapes come from the catalog’s tools-and-memory tutorial project, where a bank agent uses exactly this split to make you verify once, not three times.

skills/recommend_plant/skill.md — three languages, one file

---
name: Recommend Plant
description: >
  Suggest a plant for the customer's room. Activate for plant advice,
  what should I get, or what survives in a given room.
---

Help the customer pick a plant for their room.

if: not session.recommend_plant.room_type
Ask what kind of room the plant is for — light level matters most.

if: session.recommend_plant.room_type
Call `suggest_plants` for @memory.recommend_plant.room_type and present
the top two options briefly. Never invent a plant name.

Three languages stacked: YAML frontmatter (config), Markdown prose (what the LLM reads), and if: lines at column zero (branching logic). The description doubles as the router’s activation hint — write your trigger phrases into it. And note the two reference styles: session.x.y in the if: conditions, @memory.x.y in the prose. They are not interchangeable — that’s silent traps #4 and #5, both waiting for you in Chapter 3.

The rest

.env.example (committed, names your two keys, values empty), .gitignore (with .env in it — that one line is what makes “paste your key in .env” safe advice), a Makefile (Chapter 4), and tools/ for shared tools.

Now prove the whole thing hangs together:

python3 scripts/lint_mantle.py
ok — 0 finding(s) across 9 check(s)

Commit it — your first commit passes through the hook you installed:

git add -A && git commit -m "Scaffold Juniper with the Mantle starter pack"

Green, saved, and you can explain every file. Next chapter we take a hammer to it.