In the banking assistant, signing in is one task, called a skill in Mantle. Its passphrase check is a Python function, called a tool. The balance and transfer tasks need the customer ID that sign-in stores; they do not need to check the passphrase themselves.
You will put that check inside the sign-in skill’s folder. This makes it a local tool, available to that skill. When you reuse the skill, you can see which code and supporting dependencies to carry with it.
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:
if context is not 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"}
)
# Writing to project memory is what lets later skills skip this work.
if context is not None:
# Bare names resolve against this skill, then the project. The pinned
# runtime also accepts qualified project names; later overwrites fail.
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
The companion keeps its Rasa imports in lib/engine.py. Each tool imports
from that module, so an SDK import change can be handled in one place.
For the pinned release, the module imports rasa.mantle directly:
# lib/engine.py
from rasa.mantle.tools.decorator import ToolContext, tool
from rasa.mantle.tools.result import ToolResult
Older versions used a different package name. This pinned project does not include a fallback for those versions.
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. When you move skills/authenticate/ into another agent, the tool comes with
the folder. You must also carry its Python dependencies: here, lib.engine
and lib.directory, plus the memory fields the tool uses. Local discovery
does not package those dependencies for you.
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.