Skip to main content
A tool is an async Python function decorated with @tool. The function signature is the schema the LLM sees, and there is no separate registration step.

Defining a tool

skills/check_balance/tools.py
tool and ToolContext come from rasa.mantle.tools.decorator, and ToolResult from rasa.mantle.tools.result. Requirements:
  • The function must be async.
  • @tool takes description as a keyword argument. @tool(description="…"). A bare @tool without it raises.
  • The return type is ToolResult.
  • Name the injected parameter context. The runtime passes it as the keyword argument context=, and leaves it out of the LLM’s schema.
Local @tool calls are subject to a wall-clock timeout. Configure it with tool_timeout in agent.yml (default 10 seconds). MCP tools use the same agent default unless the server sets its own tool_timeout in integrations.yml.

How the schema is derived

Type mapping: The generated object sets additionalProperties: false.
Each generated property carries its type. Put the meaning of an argument into the tool’s description, or name the parameter so its purpose is obvious.

Where tools are discovered

For a skill skills/<id>/, in resolution order:
  1. Skill-local: skills/<id>/tools.py and skills/<id>/tools/*.py. Both locations are scanned; no declaration needed. A name defined in tools/*.py shadows the same name in tools.py (logged as a duplicate).
  2. Shared: tools/*.py at the agent root. Only tools the skill declares via import_tools are attached to it; an undeclared shared tool is not available to that skill at all.
  3. MCP: mcp/<server-id>:<tool-name> entries in import_tools. The skill receives only those allowlisted tools; the LLM sees the bare tool name. The server must be declared under mcp_servers.
First match wins among skill-local and shared names. An MCP bare name that collides with a local or shared tool in the same skill fails rasa train — see import_tools. When a tool module cannot be imported, rasa train fails. When a referenced MCP server cannot connect, authenticate, or list tools, agent startup stops with an error naming the failure, so a misconfiguration surfaces before the first conversation rather than as a missing tool mid-turn.

Importing shared project code

While tool modules are imported, the project root is on sys.path, so a lib/ package at the agent root is importable from any tool location:
Do this at module top level. The project root is only importable during tool loading, not later when the tool is dispatched.

ToolContext

The runtime constructs one ToolContext per invocation and injects it: context.model carries two read-only values that are fixed for the loaded model: llm_config, the same provider settings the orchestrator uses from integrations.yml, and references_index, the packaged knowledge index (or None when the project ships no reference files). Use it when a tool needs to run its own model call or query the same knowledge the agent searches.
send is a coroutine, so await it: await context.send("One moment…").
context.events is a fresh list, so appending to or removing from it does not touch conversation state. Memory is the only supported write path. If the user has already interrupted the turn (voice barge-in), send drops the message.

context.memory

For reads, use either:
  • a bare name as declared in memory.yml — resolved against the active skill’s schema first, then the project-root memory.yml
  • a fully-qualified name (<skill_id>.<entry> or project.<entry>) — same visibility as conditions / prompt memory: the active skill’s public and private fields, every other skill’s public fields, and project fields.
Do not write session. prefixes here (that form is for conditions). For writes, use a bare name (active skill, then project) or a fully-qualified name for a field this skill may write. project.<entry> always targets project memory, even when the skill declares the same name. <active_skill_id>.<entry> always targets this skill’s field and does not fall through to project. Cross-skill writes stay out of scope. Do not use the internal memory manager or clear memory entries; rasa data validate rejects that. The same rule applies to literal context.memory.set(...) calls that rasa train inspects. To clear a field this skill owns, call context.memory.set(field, None). Fields another skill owns must be cleared from that skill.
  • get accepts an optional second argument used as the default. It returns that default when the entry is unset, stored as None, or outside the readable set (for example another skill’s private field). When you do not pass a default, get returns None. Any other stored value wins, including falsy values such as 0, False, or "". Stored None cannot be distinguished from a never-written entry, so both yield the default (this differs from dict.get). get never raises.
  • set writes through immediately and raises MemoryWriteError when the entry is undeclared, the name is a cross-skill write, the name collides with the reserved system.* namespace, or a project.* field is already set (ProjectMemoryAlreadySetError). YAML set_memory: steps and run_after_setting_* hooks skip an already-set project field instead of failing the step, so a later collect or correct is not rolled back.
Every key a tool writes must be declared in the skill’s memory.yml or the project-root memory.yml. rasa train rejects an undeclared write with undeclared_memory_write. This is the most common authoring failure. See the memory.yml reference.

context.set_buttons

set_buttons is only meaningful for a response button source tool. That is a local tool named by a variant’s buttons: {source: <tool_name>}. It attaches Button values to the response that triggered the invocation. Calling it outside that context raises RuntimeError.
A Button with ref writes value to that fully qualified memory field on tap. Without ref, the engine sends the tap as plain user input. Calling set_buttons again replaces the previously supplied list. Messages the tool sends with context.send() remain separate and carry no buttons. See Dynamic buttons.

ToolResult

  • llm_response: any JSON-serialisable value; this is what the LLM reads as the tool’s result. It is used for LLM-invoked tools; framework-invoked tools (ordered-block execute_tool: steps, post-write hooks) act through context.
  • A tool that only sends a message or writes memory returns a bare ToolResult().

Gating a tool

By default every skill-local tool is visible to the LLM whenever its skill is active. Add tool_constraints in the frontmatter to gate one behind a memory condition:
The tool stays out of the LLM’s schema until the condition holds, and the same check runs again before the function is called. See Conditions. Visibility rules differ slightly by tier:
  • A skill-local tool is visible unless it appears in tool_constraints with an unmet condition.
  • A shared tool must be listed in import_tools; a tool_constraints entry then gates it as well.
  • A tool named by a response variant’s buttons: {source: <tool_name>} is a response button source. The engine invokes it before delivering that response, and omits it from the LLM tool schema so the model cannot dispatch it directly. See Dynamic buttons.

Built-in framework tools

These names are reserved. A builder tool that reuses one raises at model load. Behaviors worth knowing:
  • complete_skill is withheld while an ordered block is active. The engine finishes the block when it reaches END. A hybrid skill (prose plus ordered blocks) offers complete_skill again once control returns to its prose instructions. Ordered-block-only skills never offer it. Do not add a final block step that asks the model to call complete_skill. cancel_skill stays available throughout, so the customer can still abandon the skill mid-block.
  • Once search_knowledge has run in a turn, the tool list collapses to activate, search_knowledge, and cannot_help, plus resolve_tool_confirmation when a confirmation is pending. The skill’s own tools and listen are withheld so a grounded answer neither re-drives the skill nor ends silently before presenting the retrieved information. They return on the next turn.
  • cannot_help is withheld until a search has run when a knowledge base exists, so the model cannot decline before retrieval could have answered.
  • listen alone ends the turn with no message. It is the LLM wait tool. Ordered-block action: action_listen is a separate wait step.
  • Guided collect steps, post-search answer calls, and pending tool confirmation do not offer listen.
  • If listen is mixed with text or other tools, the engine ignores listen and follows the text and sibling tools.

Post-write hooks

A tool named run_after_setting_<entry> is an internal hook, not an LLM tool. It is never offered to the model. The runtime calls it after a top-level set_fields or correct write to that entry, so you can validate the value or derive state from it.
tools/card_hooks.py
  • Returning llm_response with a non-null error key rolls the triggering write back and surfaces the message to the LLM. {"error": None} is success.
  • Writes made inside a hook do not re-trigger hooks.
  • A skill-local run_after_setting_<field> hook can write project memory after the LLM records a skill field. Shared or skill hooks named after a project field are invalid — the LLM never sets project memory.

Cancellation

A turn can end while a tool is still running, whether from a voice barge-in, a session timeout, or a dropped connection. The runtime raises asyncio.CancelledError inside the tool at whatever await it is suspended on. There is nothing to wire up. Only await points are interruptible; synchronous code between them runs to completion.

What happens to your tool

When your tool returns normally, the runtime records the call and advances the skill to its next step. When your tool is cancelled mid-await, it does neither. The step stays pending, so the same tool can run again on the next turn. Memory it already wrote with context.memory.set(...) is kept. That gap matters for a side-effecting tool: your backend may have been called successfully while the conversation holds no record of it.

Check before irreversible work

context.is_cancelled is True once the turn has been cancelled. Check it immediately before anything you cannot safely repeat, and between consecutive side effects:
Returning early is clean: the call is recorded and the step advances, with no further await for a cancellation to slip into.

Release resources on cancellation

If your tool holds a resource, catch asyncio.CancelledError, release it, and re-raise. The turn has already ended, so the runtime discards anything you return from the handler.

See also