Ratel Docs
TypeScript 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; 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 { ToolCatalog } from "@ratel-ai/sdk";
import { readFile } from "node:fs/promises";

const catalog = new ToolCatalog();
catalog.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: 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.
  • inputSchema / outputSchema — 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.
  • 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. The ExecutableTool type requires it.

The ToolCatalog surface

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

const catalog = new ToolCatalog();   // ToolCatalogOptions: { trace?, method? }

catalog.register(tool);              // ExecutableTool: metadata + execute()
catalog.search(query, topK);         // → SearchHit[]  ({ toolId, score })
catalog.buildEmbeddings();           // pre-compute the semantic/hybrid embedding cache
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 only

The constructor takes ToolCatalogOptions = { trace?: TraceSinkConfig; method?: SearchMethod }: trace wires a local trace sink (default: none), and method sets the catalog's default retrieval method (default "bm25"; see Use the discovery tools).

Two behaviors worth knowing:

  • invoke throws unknown toolId: <id> for an id that was never registered. It awaits sync and async executors alike (Executor = (input: any) => Promise<unknown> | unknown) and rethrows whatever the handler throws (after recording an invoke_error trace event), so failures surface where you call it.
  • search's full signature is search(query, topK, origin = "direct", method?). Pass "agent" to tag a search as model-initiated in telemetry, and a method to override the catalog default for one call. Unlike the capability tool, this direct path applies no top-K guard, so pass a positive integer.

Build 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. Two ways to keep it current:

// Eager: a semantic- or hybrid-default catalog embeds every tool as it is
// registered. Nothing else to call; a model-load failure throws at register.
const eager = new ToolCatalog({ method: "hybrid" });

// Explicit: a BM25-default catalog embeds nothing until you ask. Build the
// cache after registering, then opt in per call.
const lexical = new ToolCatalog();
// ...register tools...
lexical.buildEmbeddings(); // synchronous; throws if the model fails to load
lexical.search("rotate the api key", 5, "direct", "semantic");

buildEmbeddings() is incremental and keyed by id: it embeds only what is missing, so adding or re-registering one tool costs one embedding, not N, and a call with nothing left to embed is a no-op. Re-registering a tool invalidates its cached vector — on an explicit catalog, build again before the next semantic or hybrid search. A search over an incomplete cache throws instead of silently embedding the corpus.

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 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();
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", "hybrid"); // throws if embeddings are not built
registry.buildEmbeddings();                      // incremental; throws if the model fails to load
registry.setTraceSink({ kind: "memory", sessionId: "s1" });
registry.recordEvent(event);
registry.drainTraceEvents();                     // → unknown[]; memory sink only

There is no construction-time method default at this level: pick a method per call with searchWithMethod, 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 = catalog.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 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