A skill is the unit of composition in Mantle. Tools are how a skill actually does something — look up an account, lock a card, move money. Where you put a tool decides how portable the skill is.
Start with the default: local.
skills/authenticate/
skill.md
memory.yml
tools.py <- local tools live here
The tool
# skills/authenticate/tools.py
from lib.engine import ToolContext, ToolResult, tool
from lib.directory import customer_by_passphrase
@tool(description="Check the caller's passphrase and sign them in if it matches.")
async def verify_passphrase(passphrase: str, context: ToolContext = None) -> ToolResult:
"""Verify the caller's passphrase.
Args:
passphrase: The secret word the caller gives to prove identity.
"""
customer = customer_by_passphrase(passphrase)
if customer is None:
attempts = context.memory.get("passphrase_attempts") or 0
context.memory.set("passphrase_attempts", int(attempts) + 1)
return ToolResult(llm_response={"ok": False, "error": "passphrase_incorrect"})
context.memory.set("customer_id", customer["customer_id"])
context.memory.set("authenticated", True)
context.memory.set("verification_method", "passphrase")
return ToolResult(
llm_response={"ok": True, "customer_id": customer["customer_id"],
"name": customer["name"]}
)
There is no registration step. Files in a skill’s own folder are discovered
automatically — no import_tools, no manifest, nothing to keep in sync.
Why the import goes through lib.engine
You would expect that first line to read
from rasa.mantle.tools.decorator import tool, and it can. This project routes
it through a one-file shim instead, because the engine package was renamed from
rasa.calm_v2 to rasa.mantle in 3.20.0.dev1 and older pins still need the old
name:
# lib/engine.py
try: # Mantle after the rename
from rasa.mantle.tools.decorator import ToolContext, tool
from rasa.mantle.tools.result import ToolResult
except ImportError: # every release published so far
from rasa.calm_v2.tools.decorator import ToolContext, tool
from rasa.calm_v2.tools.result import ToolResult
The rename has landed: 3.20.0.dev1 ships rasa.mantle and no longer ships
rasa.calm_v2 at all. Resolving the import in one file is what let these four
tool modules cross that boundary without touching any of them.
Discovery is unaffected. The loader finds tools by checking for the
_tool_description attribute that @tool sets, so where you imported the
decorator from does not matter; the re-exported objects are the real ones.
Chapter 6 covers the decorator’s own rules, which are easy to get wrong.
The signature is the schema. The function name is the tool name, the
description is what the model reads when deciding whether to call it, and the
type hints define the inputs. context is injected by the runtime and is never
visible to the model, so it must be named exactly that.
Why this one is local
verify_passphrase is the authentication workflow. No other skill should ever
call it — a balance lookup has no business checking passphrases. Keeping it in
skills/authenticate/ means the boundary is structural rather than a matter of
everyone remembering not to import it.
There is a second payoff. In a year, when you lift skills/authenticate/ into
a different agent, this tool comes with the folder. Nothing to hunt for,
nothing to discover by reading code.
The rule
Make a tool local when any of these is true:
- only one skill calls it
- it encodes that skill’s workflow rather than a general capability
- exposing it to other skills would be a mistake — it touches a system that should stay behind this skill
That last one matters in regulated domains. A tool that reaches a payment rail or an identity provider should not be quietly available to every skill in the project just because it was convenient to share.
What a tool returns
ToolResult(llm_response=...) is what the model sees. Return structured facts,
including failures:
return ToolResult(llm_response={"ok": False, "error": "passphrase_incorrect"})
Naming the failure lets the instructions branch on it. You will see that pay off in Chapter 4, where a failed lookup is what tells the agent the caller is not verified yet.
