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), thetoolIdthe model passes toinvoke_tool, and thetoolIdon 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 everysearch_capabilitieshit, so the model can call the tool without a second lookup. Both default to{}.execute: the handler, sync or async.catalog.invokeawaits it either way and rethrows whatever it throws (after recording aninvoke_errortrace event), so failures surface where you call it.registerraisesValueErrorifexecuteisNone.
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.8This 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.