Skip to content

tutorial

Chapter 2 of 6

Chapter 2 — Local tools

by Rod Rivera Published

A tool that belongs to exactly one skill lives in that skill folder, is auto-discovered, and travels with the skill.

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.