Skip to main content
A tool is any Python callable a ReActAgent can invoke to do something outside the LLM. In practice that means:
  • A plain Python function
  • A method on a class
  • Another agent (wrapped as a callable)
  • A method exposed by an MCP server
Pass any of these into tools=[...] and the agent sees them as named, schema-typed functions it can call by name.

Functions are tools

The fastest way to give an agent a tool is to pass a plain Python function.
from motus.agent import ReActAgent
from motus.models import OpenAIChatClient


async def search(query: str) -> str:
    """Search the web for a query."""
    return await web_search(query)


agent = ReActAgent(
    client=OpenAIChatClient(),
    model_name="gpt-4o",
    tools=[search],
)
Motus reads the name, the docstring, and the type hints and builds the JSON Schema the LLM sees. Sync and async functions both work.
Parameters without type annotations must have a default value. Adding docstrings to functions and descriptions to parameters is strongly recommended so the model understands when and how to call your tool.

The @tool decorator

Use @tool to customize how a function is exposed to the model: its name, description, schema, guardrails, an approval gate, or lifecycle hooks.
from motus.tools import tool


@tool(name="web_search", description="Search the web and return the top result.")
async def search(query: str) -> str:
    return await web_search(query)
tool() also works as a post-hoc patcher on a callable you don’t own:
configured = tool(third_party_fn, name="fetch", input_guardrails=[validate_url])
OptionWhat it does
nameTool name the model sees (default: function name)
descriptionTool description (default: docstring)
schemaInput schema override. Accepts a Pydantic model, an InputSchema subclass, or a raw JSON Schema dict.
input_guardrails / output_guardrailsFunctions that validate or rewrite input and output. See Guardrails.
requires_approvalWhen True, the agent pauses before running the tool and waits for human confirmation. See Human in the Loop.
on_start, on_end, on_errorPer-tool lifecycle hook callbacks. See Tracing.

Describing parameters

You have three ways to describe what a tool’s arguments mean. Pick whichever is most convenient. Define a BaseModel subclass with Field descriptors. This gives you validation constraints, nested objects, enums, and rich descriptions all in one place.
from pydantic import BaseModel, Field


class Filter(BaseModel):
    field: str = Field(description="Column to filter on")
    value: str = Field(description="Value to match")


class SearchInput(BaseModel):
    query: str = Field(description="The search query")
    filters: list[Filter] = Field(default=[], description="Optional filters")
    max_results: int = Field(ge=1, le=50, default=10, description="Max results")


@tool(schema=SearchInput)
async def search(query: str, filters: list, max_results: int = 10) -> str: ...
Nested models like Filter are expanded into the JSON Schema the model sees. The function signature is what Motus calls when the tool runs, so make sure the field names match the parameter names.

Annotated for inline descriptions

A lighter alternative when you only need to add descriptions and do not need validation. Add a string after the type in an Annotated wrapper.
from typing import Annotated


async def search(
    query: Annotated[str, "The search query"],
    max_results: Annotated[int, "Max results to return"] = 10,
) -> str: ...

Raw JSON Schema

For exact control over the schema the model sees, pass a dict directly.
@tool(schema={
    "type": "object",
    "properties": {"query": {"type": "string"}},
    "required": ["query"],
})
async def search(query: str) -> str: ...

Type mapping

When Motus infers the schema from your type hints, this is the mapping it uses.
PythonJSON Schema
str, int, float, boolstring, integer, number, boolean
list[T]array with items
dict[str, T]object with additionalProperties
T | NoneanyOf [T, null]
BaseModel, TypedDict, dataclassobject with properties
Annotated[T, "desc"]schema of T plus description

Tool collections from a class

Group related tools into a class with the @tools decorator. Public methods become tools automatically, and self is stripped from the schema.
from motus.tools import tools


@tools(prefix="db_")
class DatabaseTools:
    def __init__(self, conn_string: str):
        self.conn = connect(conn_string)

    async def query(self, sql: str) -> str:
        """Execute a SQL query."""
        return str(self.conn.execute(sql))

    async def insert(self, table: str, data: dict) -> str:
        """Insert a row."""
        ...


agent = ReActAgent(
    client=OpenAIChatClient(),
    model_name="gpt-4o",
    tools=[DatabaseTools("postgres://...")],
)
# Exposes "db_query" and "db_insert" to the model.
tools() also works as a function call on an instance you don’t own, for cases where you want to pick specific methods:
tools(some_instance, allowlist={"get", "list"}, prefix="api_")
OptionWhat it does
prefixPrepended to every tool name
include_privateExpose methods starting with _ (default False)
allowlist / blocklistFilter methods by name
method_schemasPer-method schema overrides
method_aliasesRename methods
input_guardrails / output_guardrailsDefault guardrails applied to every method
A per-method @tool overrides the class-level @tools options for that one method:
@tools(prefix="db_", input_guardrails=[log_all])
class DatabaseTools:
    @tool(name="raw_query", input_guardrails=[validate_sql])
    async def query(self, sql: str) -> str: ...
    # Exposed as "db_raw_query" with [validate_sql]

    async def insert(self, table: str, data: dict) -> str: ...
    # Exposed as "db_insert" with [log_all]

Built-in tools

Motus ships a ready-made set of tools that cover the common needs of a code-writing agent: running shell commands, reading and writing files, searching the filesystem, and keeping a checklist of its own work. Most agents that do anything with code can drop these in without writing their own. Get them from builtin_tools(), which works out of the box against your local machine.
from motus.tools import builtin_tools


agent = ReActAgent(
    client=OpenAIChatClient(),
    model_name="gpt-4o",
    tools=[*builtin_tools()],
)
ToolWhat it does
bashRun a shell command. Default 120s timeout, max 600s, output truncated to 30000 characters.
read_fileRead a file with line numbers. Accepts offset and limit (default: first 2000 lines).
write_fileWrite a file, creating parent directories if needed.
edit_fileExact string replacement. Fails on ambiguous matches unless replace_all=True.
glob_searchFind files by glob pattern.
grep_searchRegex search with context, type filters, and output modes.
to_doA checklist the agent maintains to track its own progress across a long task.
Pass skills_dir="..." to add a load_skill tool that lets the agent load self-contained instructions from disk on demand. See Skills.
With no sandbox, builtin_tools() runs bash, write_file, and edit_file directly against your machine. That means the agent can delete files, install packages, or run any shell command, just like a person with terminal access. Use a sandbox (see below) for untrusted prompts, and use approval gates on tools you do not want the agent running without permission.
You can customize an individual built-in tool by re-decorating it. The object returned by builtin_tools() has attribute access to every tool, so you can patch just one:
bt = builtin_tools()
tool(bt.bash, description="Only read-only commands", input_guardrails=[no_rm])
agent = ReActAgent(..., tools=[*bt])

Sandboxed execution

A Sandbox in Motus is an execution environment that the built-in tools run inside: you create it, the tools bound to it execute commands there, and you close it when you’re done. It is not a tool itself; it is the place tools run. Two implementations ship with Motus:
  • LocalShell, the default, runs commands directly on your host machine.
  • DockerSandbox runs everything inside a container, so a rogue command cannot touch your host.
builtin_tools() uses LocalShell when you do not pass anything. To isolate execution, create a DockerSandbox and hand it in:
from motus.tools import builtin_tools, DockerSandbox


with DockerSandbox.create(image="python:3.12") as sandbox:
    agent = ReActAgent(
        client=OpenAIChatClient(),
        model_name="gpt-4o",
        tools=[*builtin_tools(sandbox=sandbox)],
    )
    await agent("...")
For a managed sandbox with mounts, ports, or a pre-built image, use the get_sandbox() factory. It reuses a single global provider under the hood, so repeated calls do not spin up new containers.

MCP tools

Any Model Context Protocol server can be used as a tool source via get_mcp().
from motus.tools import get_mcp


# Local stdio server
async with get_mcp(
    command="npx",
    args=["-y", "@anthropic/mcp-server-filesystem", "/workspace"],
) as session:
    agent = ReActAgent(
        client=OpenAIChatClient(),
        model_name="gpt-4o",
        tools=[session],
    )
    await agent("List the Python files under /workspace.")


# Remote HTTP server
async with get_mcp(url="http://localhost:3000/mcp") as session: ...


# Stdio server running inside a Docker sandbox
async with get_mcp(
    image="node:20",
    command="npx",
    args=["@playwright/mcp", "--port", "8080"],
    port=8080,
) as session: ...
The session exposes every tool the MCP server advertises. Both patterns for managing the session work. If you pass an unconnected get_mcp(...) session to the agent without async with, the agent connects it lazily on its first run. Using async with gives you deterministic cleanup when the block exits. See MCP Integration for connection options, filtering, and renaming tools.

Agents as tools

An agent can be another agent’s tool. The caller treats it like any other entry in tools=[...].
researcher = ReActAgent(
    client=client,
    model_name="gpt-4o",
    name="researcher",
    system_prompt="You research topics in depth.",
)

orchestrator = ReActAgent(
    client=client,
    model_name="gpt-4o",
    tools=[researcher],
)
For a custom name, description, or stateful memory across calls, use researcher.as_tool(name="do_research", stateful=True). See Multi-agent for the full composition guide.

Approval gates

Mark a dangerous tool with requires_approval=True and the agent will pause before running it, emit an approval request to the caller, and resume once the caller approves.
import os


@tool(requires_approval=True)
async def delete_file(path: str) -> str:
    """Delete a file from disk."""
    os.remove(path)
    return f"Deleted {path}"
motus serve surfaces these pauses through its REST API so a client can prompt the user and respond. See Human in the Loop for the full protocol.

Registration cheat sheet

A quick reference for how to pass each kind of tool to an agent.
What you haveHow to pass it
A function you owntools=[my_func]
A function you don’t owntools=[tool(other_func, name="...")]
A class with methodstools=[MyClass()] (class decorated with @tools)
An instance you don’t owntools=[tools(instance, allowlist={"get"})]
An MCP servertools=[session] (from get_mcp(...))
Another agenttools=[other_agent] or other_agent.as_tool(...)