Use the discovery tools
Hand any loop the three capability tools from r.modelTools(), rank the catalog host-side with r.tools.search and r.recall, and pick a retrieval method per call.
r.modelTools() returns the three capability tools an agent calls itself: search_capabilities, invoke_tool, and get_skill_content. Hand them to your loop and the model gets self-service access to the whole catalog without it living in the prompt. The language-neutral contract (result shapes, defaults, and the error table) lives on Progressive disclosure tools.
import { ratel } from "@ratel-ai/sdk";
const r = ratel();
const { search_capabilities, invoke_tool, get_skill_content } = r.modelTools();Always all three, regardless of registration order — loading a skill from an empty catalog returns a structured error, not a missing tool. Each call returns fresh objects, so take the set once and reuse it; the stable set is what keeps the prompt cache warm. Tools and skills registered later are still discoverable: the capability tools search the live catalog at invocation time.
Each is a plain ExecutableTool ({ id, name, description, inputSchema, outputSchema, execute }). The official adapters expose them in framework shape; anywhere else, map those fields onto your framework's tool type.
search_capabilities
search_capabilities({ query, topKTools?, topKSkills? }) returns two independently-ranked buckets, so a relevant skill is never crowded out by matching tools:
{
"tools": {
"groups": [
{
"server": { "name": "fs" }, // grouped by server (the id prefix before "__")
"hits": [
{ "toolId": "fs__read_file", "score": 1.42, "description": "...", "inputSchema": {} }
]
}
]
},
"skills": [{ "skillId": "deploy-vercel", "score": 0.9, "description": "..." }]
}The result is typed SearchCapabilitiesResult: CapabilityToolHits grouped per server (CapabilityToolGroup), plus CapabilitySkillHits. topKTools defaults to 5 and topKSkills to 3; a missing, non-integer, or below-1 value falls back to that default, and integers above 50 are clamped to 50.
From modelTools() the tool is wired to both shared catalogs: the skills bucket is always present and fills as you register skills on r.skills.
invoke_tool
invoke_tool({ toolId, args }) runs the registered handler (r.tools.invoke(toolId, args)) and returns its result. Arguments go nested under args; a flattened call with no args is tolerated, with the remaining top-level keys treated as the arguments. On a bad call it returns a structured { error, isError: true } instead of throwing, so a model mistake (an unknown id, a non-object args, a handler that throws) stays recoverable inside the loop rather than crashing the host.
One error is special-cased for MCP auth: when the handler throws an error named UnauthorizedError, the tool returns { error: "needs_auth", isError: true, hint: "call the auth tool to re-authorize <upstream>", upstream? }, inferring the upstream server from the <server>__<tool> id. To be notified before that result returns — say, to kick off a re-auth flow — build the tool through the piecemeal factory.
get_skill_content
get_skill_content({ skillId }) returns a skill's full body on demand. It is always in the modelTools() set: on an empty skill catalog it returns a structured error rather than going missing.
Assemble the per-turn toolset
Most agents pair the capability tools with a top-K pre-filter: before each model call, ask the catalog for the few tools most relevant to the user's message and put those in the tool list. The pre-filter covers the common case in the prompt; the capability tools are the escape hatch for everything else.
const capabilityTools = Object.values(r.modelTools()); // take once, reuse every turn
// Each turn, assemble the tools the model is allowed to see:
function toolsForTurn(userMessage: string): ExecutableTool[] {
const topK = r.tools
.search(userMessage, 3) // BM25: the 3 most relevant tools for this message
.map((hit) => r.tools.catalog.getExecutable(hit.toolId))
.filter((t): t is ExecutableTool => t !== undefined);
return [...capabilityTools, ...topK];
}r.tools.search(query, topK) ranks synchronously; await r.tools.searchAsync(query, topK, method?) runs any method off the event loop. Both clamp topK to [1, 50] (invalid → 5) and are tagged origin: "direct" in telemetry — host-driven, where the model's own search_capabilities calls are tagged "agent". r.tools.catalog is the shared ToolCatalog itself, the unguarded escape hatch: getExecutable above, unclamped search, and a custom origin via catalog.search(query, topK, origin, method?).
Eager recall
The pre-filter's cache-friendly alternative: keep the tool list fixed at the capability tools, and append a synthetic search_capabilities call/result pair for the last user message instead. The model wakes up with the top-K already in context; the pair is a suffix append, so it extends the provider's cached prefix instead of busting it. The concept, and why both this and model-initiated search preserve prompt caching, is on Recall modes.
await r.recall(query) is the ranking half: the canonical SearchCapabilitiesResult (origin "direct", top-K from the recallTopK config) or null when nothing matched. A pure query — no call id is minted, so mint your own for the pair:
// Before each model call, when the last message is the user's turn:
async function appendRecall(messages: ModelMessage[], userMessage: string, turn: number) {
const result = await r.recall(userMessage);
if (result === null) return; // nothing matched: append no pair
const id = `recall_${turn}`; // any id unique within the transcript
messages.push(
{ role: "assistant", content: [{ type: "tool-call", toolCallId: id, toolName: "search_capabilities", input: { query: userMessage } }] },
{ role: "tool", content: [{ type: "tool-result", toolCallId: id, toolName: "search_capabilities", output: { type: "json", value: result } }] },
);
}AI SDK ModelMessage shape shown; any framework's tool-call message shape works. The official adapters ship this built in (appendRecall, prepareStep, recallProcessor). The capability tools stay in the list, so the model still searches on demand for anything the recall missed.
Retrieval methods
Every search ranks with one of three methods, SearchMethod = "bm25" | "semantic" | "hybrid", selected per core (the method config, forwarded to both catalogs) or per call (the optional method argument, which overrides the default; the synchronous search is BM25-only and a dense method throws pointing at searchAsync):
// Per core: every register embeds the new tools (await it), so a
// search only ever embeds the query.
const r = ratel({ method: "hybrid" });
await r.tools.register(toolA, toolB);
// Per call: searchAsync overrides the default and runs any method.
const hits = await r.tools.searchAsync("rotate the api key", 5, "semantic");Semantic and hybrid rank against a prebuilt embedding cache and throw if it is not built; a search never embeds the corpus. A core constructed with "semantic" or "hybrid" fills that cache as tools register; you cannot retrofit a BM25 core. The end-to-end workflow, including the typed failure modes, is on Semantic & hybrid search. search_capabilities (and r.recall) passes no method: it always uses the construction-time default, so switching the agent's engine means constructing the core with it.
How each method ranks is covered once, centrally: BM25 and the text projection in Keyword search, the semantic and hybrid engines (and the local embedding model's footprint) in Semantic & hybrid search.
The piecemeal factories
Assembling the funnel yourself, or working on raw catalogs with no ratel() core? The same three tools are exported as factories:
const search = searchCapabilitiesTool(catalog, skills); // id: "search_capabilities" (SEARCH_CAPABILITIES_ID)
const invoke = invokeToolTool(catalog); // id: "invoke_tool" (INVOKE_TOOL_ID)
const load = getSkillContentTool(skills); // id: "get_skill_content" (GET_SKILL_CONTENT_ID)Each takes a raw ToolCatalog / SkillCatalog — constructed directly, or a core's own via r.tools.catalog and r.skills. The returned objects are the same plain ExecutableTools.
Two behaviors modelTools() otherwise handles for you:
- Skills gating.
searchCapabilitiesTool'sskillsbucket is always present but stays empty until you pass aSkillCatalogas the second argument — and by default the tool's description advertises skills only when that catalog is non-empty at construction time. Build the tool after registering skills, or pin the clause withSearchCapabilitiesOptions.advertiseSkills(0.5.1+) — whatmodelTools()does, so its set never depends on registration order. An optional third argument advertises upstream MCP servers in the tool's description; see Register MCP servers. - Auth callback.
invokeToolTool(catalog, { onUnauthorized })(InvokeToolToolOptions) notifies you, sync or async, before theneeds_authresult returns;modelTools()takes no options, so the factory is the way to this hook.
Upgrading from 0.1.x?
searchToolsTool (id search_tools, constant SEARCH_TOOLS_ID) is still exported as
a deprecated, tools-only shim that keeps its original { groups } result shape and
topK input. Removal is tracked as RAT-250; migrate to searchCapabilitiesTool.
Next steps
Framework integrations
r.adaptTo(...) exposes the capability tools in framework shape, recall included.
Progressive disclosure tools
The full contract search_capabilities, invoke_tool, and get_skill_content expose to the model.
Semantic & hybrid search
When to opt in to embedding-based retrieval, and what it costs.
Register skills
r.skills: register Markdown playbooks that rank in their own corpus and surface next to tools — the core's SkillCatalog, exposed raw.
Semantic & hybrid search
Opt into dense retrieval in TypeScript: fix the method on ratel(), embed at register, rank with searchAsync, and handle the typed embedding errors.