Every rule in this tutorial serves one goal: a skill folder you can pick up and drop into another agent.
That works when three things are explicit.
skills/transfer_money/
skill.md -> import_tools: what it borrows
memory.yml -> public / private: what it owns
tools.py -> what it brings with it
Read those three files and you know the skill’s entire contract with the rest
of the project: it brings make_transfer, it borrows get_customer_info, it
owns four memory fields, and it reads project.customer_id. Moving it is a
copy plus whatever import_tools names.
Nothing has to be inferred by reading code, which is the difference between a skill you can move and a skill you can only rewrite.
Why it is built this way
Two forces, both from watching large teams build agents.
Bounded surface. An enterprise agent is usually several teams, each owning skills. If every team can reach every tool and every memory field, each change risks colliding with someone else’s. Local tools and private memory keep the blast radius inside one folder.
Discoverable sharing. What is shared is small, sits at the project root, and is named explicitly by whoever uses it. You can audit it in one directory listing.
Five things that will bite you
Every one of these came out of actually building this project.
Write bare, read qualified. context.memory.set("customer_id", …) works;
set("project.customer_id", …) fails validation. Reads accept both.
immutable is not write-once. It blocks all runtime writes. On
customer_id it silently breaks authentication — the write is denied and only
the server log says so.
No description on tool-written fields. Validation rejects a description
unless the field is llm_settable or collect-owned. Use a YAML comment.
Do not branch on memory tokens in prose you cannot guarantee. An
@memory.project.authenticated reference resolves to nothing when the field is
unset, and the model reads a broken sentence. Branch on a tool result instead —
fetch_balance returning not_authenticated is unambiguous.
Say what to do with information already given. “Ask for the passphrase” makes the model ask even when the caller opened with it. Spell out the case:
If the caller has already given a passphrase in what they just said, call
`verify_passphrase` with it straight away. Do not ask them to repeat it.
Getting the decorator right
Four rules, all enforced by the engine rather than by convention. The real
signature is def tool(*, description: str) — keyword-only, and the description
is required:
@tool # TypeError: takes 0 positional arguments but 1 was given
@tool("does a thing") # TypeError: same
@tool(description="…") # correct
It must be async. The executor awaits the call, so a synchronous function
fails at the await.
The parameter must be named exactly context. The runtime invokes tools as
await func(context=ctx, **tool_args) — a keyword argument. Call it ctx and
you get an unexpected-keyword error.
Every tool needs that parameter, even unused ones. There is no signature
inspection; context=ctx is always passed. A tool declared as
async def f(account_number: str) breaks. Give it context: ToolContext = None
and ignore it — the default also keeps the function directly unit-testable.
Surviving the rename
Rasa renamed the engine package from rasa.calm_v2 to rasa.mantle in
3.20.0.dev1, and the old path is gone rather than aliased — so the same source
cannot import both without help.
This project resolves it once, in lib/engine.py, and every tool imports from
there:
from lib.engine import ToolContext, ToolResult, tool
One file to change instead of four, and nothing to change at all once the rename lands. Two things worth knowing if you copy the pattern:
- It assumes the rename is path-only. If the API also changes shape, the
trybranch will import cleanly and then fail somewhere less obvious. - Delete the fallback once the old path is gone, rather than leaving it indefinitely — a shim that outlives its reason becomes a puzzle for whoever reads it next.
Where to go next
Skills are the foundation, and more of Mantle is being built on top of them. The next things worth reading:
- Tool constraints —
requires:and confirmation gates, used here onmake_transfer - Scoped instructions — narrowing what the model may do inside a skill
- The Mantle documentation, which is a living document updated with every release, alongside a per-version changelog
Two questions worth sending back to Rasa as you build: if nearly all your tools end up global, and if you find yourself wanting to widen private memory often. Both would say something about where the boundaries are drawn.
