Ratel Docs
Python SDK

Register tools

Define executable tools, register them in a ToolCatalog, rank without execution via ToolRegistry, and spot-check what surfaces.

Everything the model can reach lives in a ToolCatalog: local functions, upstream MCP servers' tools, or both, ranked as one surface. This page covers local tools; upstream servers are on Register MCP servers.

The anatomy of an executable tool

An executable tool pairs the metadata Ratel indexes with the handler that runs it. Registering a local tool is one async register call. Metadata is indexed synchronously, and running the coroutine also drives embedding and surfaces its errors:

import asyncio

from ratel_ai import ExecutableTool, ToolCatalog

catalog = ToolCatalog()
asyncio.run(catalog.register(
    ExecutableTool(
        id="read_file",
        name="read_file",
        description="Read a file from local disk and return its textual contents.",
        input_schema={
            "type": "object",
            "properties": {"path": {"type": "string", "description": "absolute path to the file"}},
            "required": ["path"],
        },
        output_schema={"type": "object", "properties": {"contents": {"type": "string"}}},
        execute=lambda args: {"contents": open(args["path"]).read()},
    )
))

What each field is for:

  • id: the stable handle everything else uses: catalog.invoke(id, args), the toolId the model passes to invoke_tool, and the toolId on every search hit. Unique per catalog; MCP-ingested tools get a server-namespaced id automatically.
  • name: the tool's name where it appears as a direct tool definition, e.g. when you pre-filter top-K hits into the model's tool list.
  • description: what the model reads to pick the tool, and the main text BM25 ranks against. This is the field that decides whether the tool is ever found; see the definition-writing guidance in Keyword search.
  • input_schema / output_schema: JSON Schema for the arguments and the result. The input schema rides every search_capabilities hit, so the model can call the tool without a second lookup. Both default to {}.
  • execute: the handler, sync or async. catalog.invoke awaits it either way and rethrows whatever it throws (after recording an invoke_error trace event), so failures surface where you call it. register raises ValueError if execute is None.

The ToolCatalog surface

The catalog is the registry plus an executor per tool. The methods you will use:

import asyncio

from ratel_ai import ExecutableTool, ToolCatalog


async def main() -> None:
    catalog = ToolCatalog()                 # ToolCatalog(trace=None, method="bm25", embedding=None)
    await catalog.register(                 # metadata + execute
        ExecutableTool(
            id="echo",
            name="echo",
            description="Repeat a message back to the caller.",
            input_schema={"properties": {"text": {"type": "string"}}},
            output_schema={"properties": {"text": {"type": "string"}}},
            execute=lambda args: {"text": args["text"]},
        )
    )
    hits = catalog.search("repeat a message", 5)  # → list[SearchHit]; sync, BM25-only
    assert hits[0].tool_id == "echo"
    await catalog.search_async("repeat a message", 5)  # async; any retrieval method
    assert catalog.has("echo")              # → bool
    assert catalog.get("echo") is not None  # → Tool | None
    assert catalog.get_executable("echo") is not None
    print(await catalog.invoke("echo", {"text": "hello"}))
    catalog.drain_trace_events()             # → list[dict]; memory sink only

asyncio.run(main())

invoke calls the handler, awaits it only if it returned a coroutine (so sync and async executors both work), and re-raises whatever it throws after recording an invoke_error trace event. search defaults to origin="direct"; pass catalog.search(query, k, "agent") to tag a search as model-initiated in telemetry.

Three constructor keywords: trace= wires a local trace sink (default: none), method= sets the catalog's default retrieval method (default "bm25"), and embedding= selects the embedding model for semantic/hybrid (default bge-small-en-v1.5). The per-call override lives on Use the discovery tools; how the engines rank is on Semantic & hybrid search.

The embedding cache

BM25 needs no preparation. Semantic and hybrid retrieval rank against a pre-built embedding cache (a search embeds only the query, never the corpus), so the cache must cover every tool before the first such search. register is what fills it:

async def main():
    # A semantic- or hybrid-default catalog embeds every tool as it is
    # registered. register is a coroutine, so await it; pass a whole batch at
    # once to embed it in one request. A model-load or embedding failure
    # surfaces here, as an EmbedderError.
    catalog = ToolCatalog(method="hybrid")
    await catalog.register(tools)  # one tool or an iterable
    return await catalog.search_async("rotate the api key", 5, method="semantic")


hits = asyncio.run(main())

Embedding is incremental and keyed by id: registering or re-registering one tool costs one embedding, not N. There is no separate build_embeddings step and no way to retrofit a "bm25" catalog; it loads no model and holds no vectors, so construct with "semantic" or "hybrid" up front. A model or dimension change is not recovered in place: build a new catalog and re-register. A semantic or hybrid search over a catalog with no embeddings raises a typed EmbedderError instead of silently embedding the corpus. The full dense workflow, including error handling, is on Semantic & hybrid search.

By default the first embedding loads the bundled local model (BAAI/bge-small-en-v1.5) in-process, downloading it into the HuggingFace cache on first use: no service, no API key. The source is configurable via the embedding= keyword: another HuggingFace model, a local checkpoint, Ollama, or an OpenAI-compatible endpoint, all on Embedding models. The default model's footprint, offline use, and when the engines are worth it are on Semantic & hybrid search.

ToolRegistry: ranking without execution

Need only the ranking, and you will dispatch tool calls yourself? ToolRegistry is the metadata-only BM25 index underneath ToolCatalog, with no executors and no capability tools. It takes positional metadata rather than a dataclass:

import asyncio

from ratel_ai import ToolRegistry

registry = ToolRegistry()
asyncio.run(registry.register(
    "read_file",
    "read_file",
    "Read a file from local disk and return its textual contents.",
    {"properties": {"path": {"type": "string"}}},
    {"properties": {"contents": {"type": "string"}}},
))

registry.search("read a text file", 5)
# → [SearchHit(tool_id="read_file", score=1.42, rank=0, fused=False), ...]

Every hit carries rank and fused alongside score. Order and threshold on rank — the 0-based position, stable across retrieval methods. score's scale is not: fused=True means a Reciprocal Rank Fusion scale (hybrid, or a fused usage arm), False a raw BM25/cosine score.

SkillRegistry is the same executor-free index over skills: register(id, name, description, tags, tools, metadata, body) plus the whole-corpus replace_all (see Register skills), with hits as SkillHit (.skill_id, .score, .rank, .fused). Both registries also expose search_with_origin, search_with_method (sync, BM25-only), search_async (any method), and the trace-sink plumbing (set_trace_sink, record_event, drain_trace_events) that the catalogs build on.

Spot-check what you registered

Verify a registration the way the model will find it: run a direct search and check that the expected ids surface. Local and upstream tools rank together.

hits = catalog.search("read a text file from disk", 5)
for hit in hits:
    print(hit.tool_id, hit.score)
# read_file 2.1
# fs__read_file 1.8

This is the same ranking search_capabilities runs on the model's behalf. A direct catalog.search is tagged origin: "direct" in telemetry, the capability tool's searches "agent". If a tool you expect near the top doesn't surface, the fix is almost always its wording, not the ranking.

Next steps

On this page