Register tools
Define executable tools, register a batch in one r.tools.register call, search and invoke through the guarded handle, and drop to the shared ToolCatalog or ToolRegistry underneath.
Everything the model can reach lives in one shared tool catalog inside the ratel() core: local functions, upstream MCP servers' tools, or both, ranked as one surface. You work with it through r.tools, a guarded handle. This page covers local tools; MCP ingestion has its own page.
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 register call:
import { ratel } from "@ratel-ai/sdk";
import { readFile } from "node:fs/promises";
const r = ratel();
await r.tools.register({
id: "read_file",
name: "read_file",
description: "Read a file from local disk and return its textual contents.",
inputSchema: {
type: "object",
properties: { path: { type: "string", description: "absolute path to the file" } },
required: ["path"],
},
outputSchema: { type: "object", properties: { contents: { type: "string" } } },
execute: async ({ path }) => ({ contents: await readFile(path, "utf8") }),
});What each field is for:
id: the stable handle everything else uses:r.tools.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.inputSchema/outputSchema: 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.execute: the handler, sync or async.invokeawaits it either way and rethrows whatever it throws (after recording aninvoke_errortrace event), so failures surface where you call it. TheExecutableTooltype requires it.
The r.tools surface
register is variadic: pass the whole batch of native ExecutableTools in one call. The rest of the handle:
await r.tools.register(readFileTool, writeFileTool, listDirTool);
r.tools.search(query, topK); // → SearchHit[] ({ toolId, score }); synchronous, BM25-only
await r.tools.searchAsync(query, topK); // → Promise<SearchHit[]>; any retrieval method
r.tools.has(toolId); // → boolean
r.tools.get(toolId); // → Tool | undefined (metadata only)
await r.tools.invoke(toolId, args); // run the handler, return its result
r.tools.catalog; // → ToolCatalog; the unguarded layer underneathThree behaviors worth knowing:
registervalidates synchronously: a missingexecute, a reserved id, or a framework-shaped tool (register those on an adapter view) throws at the call site. A duplicate id replaces the tool in place. The returned promise resolves once the batch is indexed — and, on a semantic/hybrid core, embedded — and rejects on an embedding failure. One call is one embedding pass, so batch.- Reserved ids:
search_capabilities,invoke_tool, andget_skill_contentname the capability tools themselves and cannot be registered. searchandsearchAsynctake an optional trailingmethodand clamptopKto [1, 50] (an invalid value falls back to 5). Synchronoussearchis BM25-only: a"semantic"/"hybrid"method throws with guidance to callsearchAsync, which runs any method off the event loop.
The ToolCatalog underneath
r.tools guards one shared ToolCatalog — the same catalog r.modelTools() and r.recall(query) rank. r.tools.catalog is the unguarded driver-level escape hatch: unclamped search with an explicit telemetry origin, recordEvent, and drainTraceEvents.
const catalog = r.tools.catalog; // or assemble piecemeal: new ToolCatalog(options)
await catalog.register(tool); // one ExecutableTool or an array; embeds on a semantic/hybrid catalog
catalog.search(query, topK); // → SearchHit[]; synchronous, BM25-only, no top-K clamp
await catalog.searchAsync(query, topK); // → Promise<SearchHit[]>; any retrieval method
catalog.has(toolId); // → boolean
catalog.get(toolId); // → Tool | undefined (metadata only)
catalog.getExecutable(toolId); // → ExecutableTool | undefined (metadata + execute)
await catalog.invoke(toolId, args); // run the handler, return its result
catalog.recordEvent(event); // inject a trace event into the active sink
catalog.drainTraceEvents(); // → unknown[]; memory sink onlyAssembled piecemeal, the constructor takes ToolCatalogOptions = { trace?: TraceSinkConfig; method?: SearchMethod; embedding?: EmbeddingSpec } — the same three fields RatelConfig forwards to both internal catalogs, so ratel({ method, embedding, trace }) covers them: trace wires a local trace sink (default: none), method sets the default retrieval method (default "bm25"; see Use the discovery tools), and embedding selects the embedding model for semantic/hybrid (default bge-small-en-v1.5). Some helpers take the raw catalog: registerMcpServer(r.tools.catalog, …).
Two behaviors worth knowing:
invokethrowsunknown toolId: <id>for an id that was never registered. It awaits sync and async executors alike (Executor = (input: any, context?: unknown) => Promise<unknown> | unknown;contextis an opaque invocation context forwarded unchanged throughinvoke_tool) and rethrows whatever the handler throws (after recording aninvoke_errortrace event), so failures surface where you call it.search's full signature issearch(query, topK, origin = "direct", method?);searchAsyncmirrors it and returns aPromise. Pass"agent"to tag a search as model-initiated in telemetry. Synchronoussearchis BM25-only; a"semantic"/"hybrid"methodthrows with guidance to callsearchAsync, which runs any method off the event loop. Unliker.tools.searchand the capability tool, this direct path applies no top-K guard, so pass a positive integer.
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:
// A semantic- or hybrid-default core embeds every tool as it is
// registered. register is async, so await it; pass the whole batch in
// one call to embed it in one request. A model-load or embedding
// failure surfaces here, as an EmbedderError.
const r = ratel({ method: "hybrid" });
await r.tools.register(...tools);
const hits = await r.tools.searchAsync("rotate the api key", 5, "semantic");Embedding is incremental and keyed by id: registering or re-registering one tool costs one embedding, not N. There is no separate buildEmbeddings step and no way to retrofit a "bm25" catalog; it loads no model and holds no vectors, so pass method: "semantic" or "hybrid" to ratel() up front. A model or dimension change is not recovered in place: build a new core and re-register. A semantic or hybrid search over a catalog with no embeddings throws 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 option — ratel({ method: "semantic", embedding: … }): 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. Register the same definitions minus execute, search, and route the winning ids through your own dispatcher.
import { ToolRegistry, type Tool } from "@ratel-ai/sdk";
const registry = new ToolRegistry();
await registry.register({
id: "read_file",
name: "read_file",
description: "Read a file from local disk and return its textual contents.",
inputSchema: { properties: { path: { type: "string" } } },
outputSchema: { properties: { contents: { type: "string" } } },
});
registry.search("read a text file", 5);
// → [{ toolId: "read_file", score: 1.42 }, ...]The registry exposes the full ranking and tracing surface, minus executors:
registry.search(query, topK); // BM25, origin "direct"
registry.searchWithOrigin(query, topK, "agent"); // unknown origin strings fall back to "direct"
registry.searchWithMethod(query, topK, "direct", "bm25"); // sync, BM25-only; semantic/hybrid throw
await registry.searchWithMethodAsync(query, topK, "direct", "hybrid"); // any method, off the event loop
registry.setTraceSink({ kind: "memory", sessionId: "s1" });
registry.recordEvent(event);
registry.drainTraceEvents(); // → unknown[]; memory sink onlyThere is no construction-time method default at this level: pick a method per call with searchWithMethod (BM25) or searchWithMethodAsync (any method), or use the catalog wrappers. SkillRegistry is also exported and mirrors the same surface over Skill / SkillHit, the metadata-only index underneath SkillCatalog.
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.
const hits = r.tools.search("read a text file from disk", 5);
console.log(hits.map((h) => `${h.toolId} ${h.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 r.tools.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
Quickstart
Install @ratel-ai/sdk, create the ratel() core, register your tools and prompt playbooks, and hand any agent loop the three capability tools.
Register MCP servers
Ingest an MCP server's tools into the ratel() core with registerMcpServer: namespaced ids, the returned handle, error propagation, and many servers on one ranked surface.