# What is Ratel? Source: https://www.ratel.sh/docs Keep reading to learn more, or [get started](https://www.ratel.sh/docs/quickstart) right away. ## Why Ratel? * **Lean context:** keeps every tool schema out of the prompt with [progressive disclosure](https://www.ratel.sh/docs/optimization#progressive-disclosure), and cuts context with [benchmark-backed savings](https://www.ratel.sh/docs/optimization#efficiency). * **Zero setup:** in-process [BM25 keyword search](https://www.ratel.sh/docs/retrieval-and-search#keyword-search): no vector database, no API key, no service; [semantic & hybrid ranking](https://www.ratel.sh/docs/retrieval-and-search#semantic-and-hybrid-search) when you opt in. * **One catalog:** [tools, MCP servers, and skills](https://www.ratel.sh/docs/tools-mcp-skills), all searchable and runnable through the same three capability tools. * **Two SDKs:** [TypeScript](https://www.ratel.sh/docs/typescript/quickstart) and [Python](https://www.ratel.sh/docs/python/quickstart) over one [Rust engine](https://www.ratel.sh/docs/architecture), with [Vercel AI SDK](https://www.ratel.sh/docs/typescript/framework-integrations#vercel-ai-sdk), [Mastra](https://www.ratel.sh/docs/typescript/framework-integrations#mastra), and [Pydantic AI](https://www.ratel.sh/docs/python/framework-integrations) integrations. * **Observable:** the whole search-and-invoke funnel as [local trace events or OpenTelemetry spans](https://www.ratel.sh/docs/observability). * **Open:** [Apache-2.0 engine, MIT everything else](https://www.ratel.sh/docs/licensing), measured in the open by [ratel-bench](https://www.ratel.sh/docs/benchmark). ## Get started Follow the [Quick start](https://www.ratel.sh/docs/quickstart) (the fastest path lets your coding agent integrate Ratel for you), or [install](https://www.ratel.sh/docs/install) the SDK and jump straight to the [TypeScript](https://www.ratel.sh/docs/typescript/quickstart) or [Python](https://www.ratel.sh/docs/python/quickstart) quickstart. What the skills do, how they chain, and how observability fits is on [Skillset](https://www.ratel.sh/docs/skillset). [What's new](https://www.ratel.sh/docs/whats-new) tracks releases, and the [FAQ](https://www.ratel.sh/docs/faq) answers common questions. Using Claude Code or Codex? [Ratel Local](https://www.ratel.sh/docs/local/quickstart) puts the same capability contract in front of your existing MCP servers. ## Features Everything Ratel does, one page per use case; two are coming soon. [Optimize agent capabilities](https://www.ratel.sh/docs/optimization) Keep the model's tool surface minimal and retrieval sharp: progressive disclosure, BM25 keyword search, and opt-in semantic and hybrid ranking. [Observe your agent](https://www.ratel.sh/docs/observability) The whole search-and-invoke funnel as local trace events, or exported as OpenTelemetry spans. [Self-improving agent](https://www.ratel.sh/docs/self-improvement) Ranking that learns from your agent's real usage: searches that end in an invoke boost what history supports. Experimental opt-in. **Managing chat history (coming soon)** Keep long-running conversations lean without losing what matters. **Agent memory (coming soon)** Long-lived memory your agent can store and retrieve like any other capability. ## How it works Curious? Read [Architecture](https://www.ratel.sh/docs/architecture) for the turn-by-turn flow, [Tools, MCP & Skills](https://www.ratel.sh/docs/tools-mcp-skills) for what the catalog holds, and [Optimize agent capabilities](https://www.ratel.sh/docs/optimization#efficiency) for exactly what changes in the model's context. ## Support and issues For bugs and questions, open an issue on [github.com/ratel-ai/ratel](https://github.com/ratel-ai/ratel/issues). ## Community Join our brand-new [Discord](https://discord.gg/75vAPdjYqT) to ask questions and follow development. --- # Quick start Source: https://www.ratel.sh/docs/quickstart The fastest way to adopt Ratel is to not write the integration yourself. Ratel ships five open-source skills for coding agents (Claude Code, Cursor, Codex, and 40+ others) that audit your agent codebase, plan its observability, integrate Ratel, and fix what the audit finds. Prefer to write the code by hand? Jump to [the SDK quickstarts](#write-the-code-yourself). ## Install the skills **npm** ```bash npx skills add ratel-ai/skills -y --all ``` **pnpm** ```bash pnpm dlx skills add ratel-ai/skills -y --all ``` **yarn** ```bash yarn dlx skills add ratel-ai/skills -y --all ``` **bun** ```bash bunx skills add ratel-ai/skills -y --all ``` That installs all five skills into the current project (`./.claude/skills/` for Claude Code, the equivalent location for other supported agents). The `-y` flag accepts all prompts; `--all` installs all skills into all agents; add `-g` to install globally (`~/.claude/skills/`). ## Paste this into your coding agent Want a read on your agent first? The assessment is free and static (no setup): ```text Run npx skills add ratel-ai/skills --all and use the skills to assess the agents in this codebase ``` Ready to wire Ratel in? Go straight to the integration plan: ```text Run npx skills add ratel-ai/skills --all and use the skills to integrate Ratel in this project. ``` Either entry point pulls in the whole suite. The assessment writes a report to `.ratel/ratel-assessment-.md`, plus a branded, scored HTML version alongside it. The report ends with "Recommended next steps": every finding routes to the right follow-up skill, so you don't have to know the arc up front. What the five skills do, how they chain, and how observability fits: [Skillset](https://www.ratel.sh/docs/skillset). ## Write the code yourself Everything the skills plan and apply, you can also write by hand: register your tools and prompt playbooks in Ratel catalogs, then hand your agent the three capability tools: `search_capabilities`, `invoke_tool`, `get_skill_content`. The per-language quickstarts cover both starting from scratch and migrating an existing agent: [TypeScript quickstart](https://www.ratel.sh/docs/typescript/quickstart) Install @ratel-ai/sdk, create the ratel() core, and run the discovery loop: no framework required. [Python quickstart](https://www.ratel.sh/docs/python/quickstart) The same path with ratel-ai, idiomatic Python. > **Using Claude Code or Codex as your agent runtime?** > > [Ratel Local](https://www.ratel.sh/docs/local/quickstart) puts the same capability contract in front of your > existing MCP servers: no code at all. --- # Install Source: https://www.ratel.sh/docs/install Ratel ships as [two SDKs over one Rust core](https://www.ratel.sh/docs/sdks), plus [Ratel Local](https://www.ratel.sh/docs/local) for MCP clients such as Claude Code, Codex, and Cursor. No hosted Ratel service or API key is required. ## SDK **TypeScript** ```bash npm install @ratel-ai/sdk ``` ESM, **Node.js 20.6 or newer**. Full guide (native targets, optional packages, embedding model): [TypeScript install](https://www.ratel.sh/docs/typescript/install). Then the [TypeScript quickstart](https://www.ratel.sh/docs/typescript/quickstart). **Python** ```bash pip install ratel-ai # extras: pip install 'ratel-ai[mcp]' # register upstream MCP servers pip install 'ratel-ai[otlp]' # OTLP telemetry export ``` **Python 3.10 or newer** for these guides; the base SDK supports 3.9, the `mcp` extra needs 3.10+. Full guide (prebuilt wheels, extras, embedding model): [Python install](https://www.ratel.sh/docs/python/install). Then the [Python quickstart](https://www.ratel.sh/docs/python/quickstart). ## Requirements * Prebuilt native targets: macOS (`darwin-arm64`, `darwin-x64`), Linux glibc (`linux-x64-gnu`, `linux-arm64-gnu`), Windows (`win32-x64-msvc`). * On those targets nothing compiles at install time: no Rust toolchain. * [Semantic & hybrid search](https://www.ratel.sh/docs/retrieval-and-search#semantic-and-hybrid-search) is opt-in and, by default, downloads a local [embedding model](https://www.ratel.sh/docs/embedding-models) on first use; BM25 needs nothing. ## Ratel Local For Claude Code or Codex, install the stable CLI and run the complete setup wizard: ```bash npm install --global @ratel-ai/ratel-local ratel-local setup ``` The binary is `ratel-local` and requires Node.js 20.6 or newer. Setup prepares the persistent daemon, detects Claude Code and Codex, connects selected agents through the plugin-first flow, and separately offers existing MCP servers and skills for import. Follow the [Ratel Local quickstart](https://www.ratel.sh/docs/local/quickstart). Cursor uses [manual `mcp.json` setup](https://www.ratel.sh/docs/local/cursor). The Rust crate [`ratel-ai-core`](https://www.ratel.sh/docs/packages/rust/ratel-ai-core) underlies both SDKs; it is a reference, not an install target. ## Next steps [Quick start](https://www.ratel.sh/docs/quickstart) The fastest path in: your coding agent integrates Ratel with the skills suite. [TypeScript quickstart](https://www.ratel.sh/docs/typescript/quickstart) Register a tool and a skill with @ratel-ai/sdk, let the agent find them. [Python quickstart](https://www.ratel.sh/docs/python/quickstart) The same flow with ratel-ai. --- # What's new Source: https://www.ratel.sh/docs/whats-new Ratel SDK versions track **`ratel-ai-core`**, the Rust engine both SDKs bundle. Its changelog is below. SDK and adapter features ship in each package's own changelog — the 0.6.0 experimental adaptive usage ranking ([ADR-0014](https://github.com/ratel-ai/ratel/blob/main/docs/adr/0014-adaptive-usage-ranking.md)), the 0.7.0 whole-catalog skill reload `SkillCatalog.replaceAll` / `replace_all` ([ADR-0015](https://github.com/ratel-ai/ratel/blob/main/docs/adr/0015-whole-catalog-skill-reload.md)), and the `@ratel-ai/vercel-ai-sdk` and `@ratel-ai/mastra` adapters, for example — so also check the package changelogs [listed at the end](#looking-for-a-specific-package). See [Changelog](https://www.ratel.sh/docs/packages/rust/ratel-ai-core/changelog). ## Looking for a specific package? The SDK and telemetry packages version independently. Their changelogs live in the Reference section: * **Rust**: [`ratel-ai-core`](https://www.ratel.sh/docs/packages/rust/ratel-ai-core/changelog) · [`ratel-ai-telemetry`](https://www.ratel.sh/docs/packages/rust/ratel-ai-telemetry/changelog) * **TypeScript**: [`@ratel-ai/sdk`](https://www.ratel.sh/docs/packages/typescript/sdk/changelog) · [`@ratel-ai/vercel-ai-sdk`](https://www.ratel.sh/docs/packages/typescript/vercel-ai-sdk/changelog) · [`@ratel-ai/mastra`](https://www.ratel.sh/docs/packages/typescript/mastra/changelog) · [`@ratel-ai/telemetry`](https://www.ratel.sh/docs/packages/typescript/telemetry/changelog) * **Python**: [`ratel-ai`](https://www.ratel.sh/docs/packages/python/ratel-ai/changelog) · [`ratel-ai-telemetry`](https://www.ratel.sh/docs/packages/python/ratel-ai-telemetry/changelog) Each package page next to them carries the raw generated API reference. Start at the [package list](https://www.ratel.sh/docs/packages). --- # FAQ Source: https://www.ratel.sh/docs/faq ## Basics ### Do I need a vector database or an API key? No. The default is [BM25 keyword search](https://www.ratel.sh/docs/retrieval-and-search#keyword-search) running in your process: no model, no vector database, no service, no API key. [Semantic and hybrid ranking](https://www.ratel.sh/docs/retrieval-and-search#semantic-and-hybrid-search) are opt-in and local by default: a small embedding model downloads once on first use and runs CPU-only. The [embedding source is configurable](https://www.ratel.sh/docs/embedding-models): a remote OpenAI-compatible endpoint is the one option that leaves your process and can need an API key. ### How is this different from passing all my tools to the model? Without Ratel, every schema is sent on every turn, so prompt cost scales with catalog size. With Ratel, the model reads only the top-K matches plus three capability tools, however large the catalog grows. [Efficiency](https://www.ratel.sh/docs/optimization#efficiency) shows the before/after; [progressive disclosure](https://www.ratel.sh/docs/optimization#progressive-disclosure) explains the mechanism. ### Does hiding tools behind search hurt accuracy? The [benchmark](https://www.ratel.sh/docs/benchmark) says no: on the BFCL suite, 86–91% token savings come with accuracy at or near baseline, and smaller models actually gain. See [Efficiency](https://www.ratel.sh/docs/optimization#efficiency) for the full table and [ratel.sh/benchmark](https://www.ratel.sh/benchmark) for live results. ### What's the difference between a skill and a tool? A tool is one operation the runtime executes. A skill is a Markdown playbook the agent reads: instructions for multi-step work, ranked in its own corpus so tools never crowd out a relevant skill. See [Tools, MCP & Skills](https://www.ratel.sh/docs/tools-mcp-skills). ## Integration ### Which languages and frameworks does Ratel support? Two first-party SDKs: [`@ratel-ai/sdk`](https://www.ratel.sh/docs/typescript/quickstart) for TypeScript and [`ratel-ai`](https://www.ratel.sh/docs/python/quickstart) for Python, with mirrored APIs over one Rust core. The [Vercel AI SDK](https://www.ratel.sh/docs/typescript/framework-integrations#vercel-ai-sdk) and [Mastra](https://www.ratel.sh/docs/typescript/framework-integrations#mastra) have official adapter packages, `@ratel-ai/vercel-ai-sdk` and `@ratel-ai/mastra`; for [Pydantic AI](https://www.ratel.sh/docs/python/framework-integrations) the wiring is a small documented adapter. ### Can I use Ratel with Claude Code, Codex, or Cursor without writing code? Yes. [Ratel Local](https://www.ratel.sh/docs/local) is one MCP server that fronts your whole tool estate and exposes just the capability tools; configuration is the only integration work. Claude Code and Codex can follow the [Ratel Local quickstart](https://www.ratel.sh/docs/local/quickstart). Cursor uses [one manual MCP entry](https://www.ratel.sh/docs/local/cursor); Ratel Local has no Cursor import or link adapter. ### Should I use Ratel Local or the SDK? Pick [Ratel Local](https://www.ratel.sh/docs/local) when your tools already live behind MCP servers and you don't want to change agent code. Pick an [SDK](https://www.ratel.sh/docs/sdks) when you own the agent loop and want in-process control. Both expose the same capability tools. ### Do my existing MCP servers work with Ratel? Yes. Upstream MCP tools land in the same catalog as your local functions and rank as one surface. See [Tools, MCP & Skills](https://www.ratel.sh/docs/tools-mcp-skills) and Register MCP servers ([TypeScript](https://www.ratel.sh/docs/typescript/register-mcps), [Python](https://www.ratel.sh/docs/python/register-mcps)). ### Does retrieval bypass my tools' permissions or auth? No. Permissions, authentication, and side effects still belong to the tool and its host; Ratel only changes which definitions reach the model. See [Architecture](https://www.ratel.sh/docs/architecture). ## Search quality ### When should I switch from BM25 to semantic or hybrid search? Stay on BM25 until you see missed recall: the first fix is usually [better definitions](https://www.ratel.sh/docs/retrieval-and-search#keyword-search), not a different engine. If you do opt in, hybrid is usually the safer choice. [Semantic & hybrid search](https://www.ratel.sh/docs/retrieval-and-search#semantic-and-hybrid-search) has the decision guide. ### My tool never ranks for the queries I expect. How do I fix it? Your vocabulary is the recall mechanism: BM25 indexes names, descriptions, and schema property names, so write "what it does" plus a "use when" clause in the caller's words. The checklist is on [Keyword search](https://www.ratel.sh/docs/retrieval-and-search#keyword-search). ## Privacy & performance ### Does any of my data leave my machine? Not by default: retrieval runs in-process, telemetry is off until you opt in, and message/tool content never leaves the process unless you set a content-capture mode. See [Telemetry](https://www.ratel.sh/docs/observability). ### Does Ratel work offline or air-gapped? BM25 needs nothing: no model, no service. Semantic and hybrid download an embedding model once, then load offline; a mirror endpoint covers air-gapped setups. Details on [Semantic & hybrid search](https://www.ratel.sh/docs/retrieval-and-search#semantic-and-hybrid-search). ### Does Ratel preserve prompt caching? Yes, in both [recall modes](https://www.ratel.sh/docs/recall-modes). The capability tools stay in the tool list unchanged, and recall only appends to the transcript: eager recall adds a ready-made `search_capabilities` result for the last user message; in on-demand search the model calls the tool itself. Either way the cached prefix keeps growing instead of busting. The optional top-K pre-filter instead puts each turn's hits in the tool list, which does invalidate the cache; the trade-off is on the same page. ### What runtime overhead does Ratel add? BM25 retrieval runs deterministically in your process with no model or service call. Semantic search adds a one-time model download and CPU-only inference; telemetry is a no-op until you opt in. See [Telemetry](https://www.ratel.sh/docs/observability). ### I already run OpenTelemetry. Will Ratel's telemetry conflict? No. The SDK registers no provider of its own — it emits through the OpenTelemetry API, so your existing provider already receives the `ratel.*` / `gen_ai.*` spans. To also dual-export to Ratel, add a filtered OTLP span processor and log-record processor: `ratel_span_processor` plus `ratel_log_record_processor` in Python, your own in TypeScript (there is no packaged one). See [Telemetry](https://www.ratel.sh/docs/observability). ## Project ### How is Ratel licensed? Can I use it commercially? The `ratel-ai-core` engine is Apache-2.0; the SDKs, Ratel Local, and everything else are MIT. Both permit commercial use, modification, and redistribution. See [Licensing](https://www.ratel.sh/docs/licensing). ### Where do the token-savings numbers come from? From the open harness [`ratel-ai/ratel-bench`](https://github.com/ratel-ai/ratel-bench), which runs every scenario as baseline, oracle, and ratel-full. [Benchmark](https://www.ratel.sh/docs/benchmark) covers the methodology; [ratel.sh/benchmark](https://www.ratel.sh/benchmark) has the live results. ### What's on the roadmap? Adaptive ranking and compaction are marked coming soon on [Features](https://www.ratel.sh/docs#features). [Ratel Cloud](https://www.ratel.sh/docs/cloud) (hosted context analytics) is also coming soon. ### Can my agent read these docs? Yes. These docs publish [`/docs/llms.txt`](https://www.ratel.sh/docs/llms.txt) and [`/docs/llms-full.txt`](https://www.ratel.sh/docs/llms-full.txt), regenerated from the authored content on every build. Each page is also available as markdown at `/docs/.md` (for example [`/docs/quickstart.md`](https://www.ratel.sh/docs/quickstart.md)). --- # Optimize agent capabilities Source: https://www.ratel.sh/docs/optimization An agent's direct tool list should carry only what the current turn needs; everything else stays in the catalog, out of the prompt. Stack up a few dozen definitions and each turn opens with the model reading tools it will never call, paying in tokens and accuracy. Ratel keeps the catalog searchable instead, so the model pulls the rest in on demand. ## Progressive disclosure Register everything once (tools and upstream MCP servers into a `ToolCatalog`, skills into a `SkillCatalog` — in TypeScript, `ratel()` holds both behind one handle) and hand the model three **capability tools** instead of the whole list: * `search_capabilities` finds tools and skills by intent, * `invoke_tool` runs a hit by id, * `get_skill_content` loads a skill's playbook. Each turn now carries the top-K hits for the current message plus those three tools, no matter how large the catalog grows. The rest of the catalog stays one search away: the three tools are the model's escape hatch when the pre-filtered set is not enough. Whether the host recalls eagerly or the model searches on demand — and how each choice, including this pre-filter, interacts with prompt caching — is on [Recall modes](https://www.ratel.sh/docs/recall-modes). **Python** ```python from ratel_ai import ( ToolCatalog, SkillCatalog, search_capabilities_tool, invoke_tool_tool, get_skill_content_tool, ExecutableTool, ) catalog = ToolCatalog() skills = SkillCatalog() # ...register every tool, MCP server, and skill once... # The three capability tools reach the whole catalog. capabilities = [ search_capabilities_tool(catalog, skills), invoke_tool_tool(catalog), get_skill_content_tool(skills), ] # Each turn: the top-K hits for the user's message, plus the three tools. def tools_for_turn(user_message: str) -> list[ExecutableTool]: top_k = [ executable for hit in catalog.search(user_message, 3) # BM25: the 3 most relevant tools if (executable := catalog.get_executable(hit.tool_id)) is not None ] return [*capabilities, *top_k] ``` **TypeScript** ```ts import { ratel, type ExecutableTool } from "@ratel-ai/sdk"; const r = ratel(); // ...register every tool, MCP server, and skill once... // The three capability tools reach the whole catalog. const capabilities = Object.values(r.modelTools()); // Each turn: the top-K hits for the user's message, plus the three tools. function toolsForTurn(userMessage: string): ExecutableTool[] { const topK = r.tools .search(userMessage, 3) // BM25: the 3 most relevant tools .map((hit) => r.tools.catalog.getExecutable(hit.toolId)) .filter((t): t is ExecutableTool => t !== undefined); return [...capabilities, ...topK]; } ``` The exact request and result shapes, the error contract, and how to construct the tools are on [Progressive disclosure tools](https://www.ratel.sh/docs/progressive-disclosure-tools). ## Efficiency Ratel's value is measured in what the model reads: fewer tokens per turn, with tool selection as good or better. ### What changes in the model's context Ratel does not change your model or your prompts. It changes which tool and skill definitions reach the model on each turn: | Without Ratel | With Ratel | | ------------------------------------------------- | -------------------------------------------------------- | | Every tool schema is sent up front | Only the relevant top-K and capability tools are sent | | The model must choose from one crowded list | Local retrieval ranks candidates for the current request | | Each integration expands the model's tool surface | One catalog stays searchable behind capability tools | | Runbooks compete with tool definitions for tokens | Skill bodies load only after the model selects one | ### Why it compounds Without Ratel, prompt cost scales with catalog size: the benchmark sweeps pools from 30 to 600 tools, and the baseline pays for all of them every turn. With Ratel, the model reads the top-K hits plus three capability tools no matter how large the catalog grows. That is the mechanism behind [progressive disclosure](#progressive-disclosure). ### Benchmark-backed numbers On the BFCL tools suite (Ratel 0.4.0, dense retrieval, `ratel-full` arm vs `control-baseline`), token savings grow with catalog size while accuracy holds at or near baseline; only the smallest local model gives up a few points: | Model | Mean total tokens | Accuracy (multiple / simple split) | | ----------------- | ----------------- | ---------------------------------- | | Claude Haiku 4.5 | −86% | +26.0 / +6.5 pt | | Claude Sonnet 4.6 | −87% | +0.5 / +0.5 pt | | GPT 5.4 Mini | −89% | +1.0 / −1.0 pt | | Qwen3 4b | −91% | −5.5 / −3.3 pt | Smaller models gain the most: Haiku 4.5 jumps 26 points on the multiple split once the crowded list is gone. [Benchmark](https://www.ratel.sh/docs/benchmark) covers the methodology; the live results at [ratel.sh/benchmark](https://www.ratel.sh/benchmark) let you switch model, version, and retrieval method. ## Choosing a retrieval method Every query into the catalog is ranked. **BM25** keyword search is the default: in-process, deterministic, zero setup. **Semantic** and **hybrid** ranking are a per-catalog opt-in for when your queries paraphrase the definitions and no rewording closes the gap; they load a local embedding model and cost a little memory and register-time work. Stay on BM25 until you see missed recall; when you do opt in, hybrid is usually the safer choice. The full decision guide (what each method does and when to reach for it) is on [Keyword vs semantic vs hybrid](https://www.ratel.sh/docs/retrieval-and-search#keyword-vs-semantic-vs-hybrid). ## Next steps [Progressive disclosure tools](https://www.ratel.sh/docs/progressive-disclosure-tools) The contract the three capability tools expose to the model: request and result shapes, the error table, and how to construct them. [Retrieval and search](https://www.ratel.sh/docs/retrieval-and-search) How BM25 ranks a schema-aware projection, how to write definitions the index can find, and when to opt into semantic or hybrid. [Register tools (TypeScript)](https://www.ratel.sh/docs/typescript/register-tools) Fill the catalog the capability tools search: local functions and upstream MCP servers. [Register tools (Python)](https://www.ratel.sh/docs/python/register-tools) The same registration flow, idiomatic Python. --- # Observe your agent Source: https://www.ratel.sh/docs/observability Ratel emits telemetry on **two deliberately separate streams** ([ADR 0007](https://github.com/ratel-ai/ratel/blob/main/docs/adr/0007-telemetry-two-streams.md)): * **Local trace events**, owned by the Rust core. Structured events (`search`, `invoke_start`, `upstream_error`, ...) recorded into a sink you configure; this stream feeds the offline inspector, [Ratel Local statusline and savings reporting](https://www.ratel.sh/docs/local/statusline-telemetry), and future rerankers. Its reliability contract is query-log semantics: best-effort, sampleable, lossy on backpressure. Losing an event is acceptable, corrupting a catalog is not. * **OpenTelemetry.** Remote telemetry *is* OpenTelemetry, on two signals: `gen_ai.*` spans per a pinned semconv baseline plus a `ratel.*` overlay, and — when [content capture](#content-capture) opts in — structured Logs `EventRecord`s carrying the gated content to the sibling `/v1/logs` endpoint. Both are stock OTLP (`http/protobuf` + Bearer auth). This is the stream for Ratel Cloud, Langfuse, or your own collector. Both are off by default: the local sink is `noop` until you pass one, and spans and EventRecords are no-ops until OpenTelemetry providers exist. Pick your path: * **Local visibility, no network** → [configure a trace sink](#local-trace-sinks). * **No OpenTelemetry in your stack yet** → [greenfield setup](#greenfield-ship-spans-to-ratel-cloud): Python calls `configure_telemetry` once at startup; TypeScript hosts build and register their own providers — the SDK ships no bootstrap. * **Already running OpenTelemetry** (Langfuse, the Vercel AI SDK, your own collector) → your provider already receives the spans. [Dual-export](#already-on-opentelemetry-dual-export) the Ratel cut: `ratel_span_processor` + `ratel_log_record_processor` in Python, a filtered processor you build yourself in TypeScript. ## Local trace sinks Pass `trace` at construction — `ratel({ trace })` in TypeScript forwards it to both internal catalogs; Python passes it to the `ToolCatalog` (or `SkillCatalog`) constructor — and the core records every search, invoke, [capability-tool](https://www.ratel.sh/docs/progressive-disclosure-tools), upstream-MCP, and auth event into the sink. **Python** ```python from ratel_ai import ToolCatalog, TraceSinkConfig catalog = ToolCatalog( trace=TraceSinkConfig(kind="jsonl", session_id="session-1", path="/tmp/ratel.jsonl"), ) # Every catalog.invoke, capability-tool call, and register_mcp_server call now # appends one JSON line per event to /tmp/ratel.jsonl. ``` `TraceSinkConfig` is a dataclass: `kind` is `"noop" | "memory" | "jsonl"`; `session_id` is required for memory and jsonl, `path` for jsonl. Misconfiguration raises `ValueError` (for example `"jsonl sink requires path"`). **TypeScript** ```ts import { ratel } from "@ratel-ai/sdk"; const r = ratel({ trace: { kind: "jsonl", sessionId: "session-1", path: "/tmp/ratel.jsonl" }, }); // Every invoke, capability-tool call, and registerMcpServer call now appends one JSON line per event to /tmp/ratel.jsonl. ``` `TraceSinkConfig` is a discriminated union: `{ kind: "noop" }`, `{ kind: "memory"; sessionId }`, or `{ kind: "jsonl"; sessionId; path }`. Misconfiguration throws (for example `"jsonl sink requires path"`). | Kind | Requires | Behavior | | -------- | ----------------------- | -------------------------------------------------------------- | | `noop` | - | Drop everything. The default when no `trace` is passed. | | `memory` | a session id | Keep events in memory; drain via the catalog. Useful in tests. | | `jsonl` | a session id and a path | Append one JSON line per event to the file. | Every recorded event lands wrapped in an envelope: `{ "v": 1, "ts": , "session_id": "...", "type": "search", ...event fields }`, with the event type tagged in snake\_case. * **Draining the memory sink.** `catalog.drain_trace_events()` (Python) / `r.tools.catalog.drainTraceEvents()` (TypeScript — the drain lives on the raw `ToolCatalog` under the `r.tools` handle) returns the buffered events and removes them. It returns `[]` unless the active sink is `memory`; the `jsonl` and `noop` sinks have no drain, read the file instead. * **JSONL file behavior.** The sink creates missing parent directories, opens the file append-only with mode `0600` on Unix, and flushes after every line. Writes are best-effort: a serialization or write failure is swallowed rather than crashing the agent loop. * **Origin tagging.** Searches the model makes through `search_capabilities` are tagged `origin: "agent"`; direct `catalog.search(query, k)` calls (in TypeScript, on the raw `r.tools.catalog`) default to `"direct"`, overridable with a third argument. ### Event types The event set is defined once, in the Rust core, so every SDK emits the same shapes. The full `type` vocabulary: | `type` | Recorded on | Emitted by | | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- | | `search` | every `ToolCatalog.search`, including the one inside `search_capabilities` | SDK | | `index_churn` | a tool added to or removed from the index | SDK | | `skill_search` | every `SkillCatalog.search`, including the one inside `search_capabilities` | SDK | | `skill_churn` | a skill added to or removed from the index: `register` emits add only; a whole-corpus reload (`replace_all` / `replaceAll`) emits either kind, is the only source of remove, and fires for real changes only | SDK | | `skill_invoke` | every `SkillCatalog.invoke`, including via `get_skill_content` | SDK | | `invoke_start`, `invoke_end`, `invoke_error` | around every `ToolCatalog.invoke`: start, then end on success or error on failure (the error is rethrown after recording) | SDK | | `gateway_search` | every `search_capabilities` call, recorded at the capability-tool layer | SDK | | `gateway_invoke`, `gateway_error` | an `invoke_tool` call succeeding or failing (unknown id, unauthorized upstream, executor error); `gateway_error` also covers `get_skill_content` with an unknown skill id | SDK | | `upstream_register` | every `registerMcpServer` / `register_mcp_server` | SDK | | `upstream_invoke`, `upstream_error` | a proxied upstream-MCP tool call succeeding or failing | SDK | | `auth_refresh` | an upstream token refresh attempt | Ratel Local | | `auth_needs` | an upstream MCP server needing (re)authorization | Ratel Local | | `auth_flow_start`, `auth_flow_end` | the OAuth flow for an upstream starting and finishing | Ratel Local | | `embedder_load` | once, on the first (cold) load of the embedding model | SDK | | `embedder_download` | once, when a configured embedding model is actually downloaded to the cache (a cold fetch), with the real byte size | SDK | | `embedder_model_mismatch` | a semantic/hybrid search hitting embeddings built with a different model than the one now configured; retrieval fails until the cache is rebuilt | SDK | | `embedder_pooling_assumed` | once, when an in-process model's pooling could not be detected and a mode was assumed | SDK | | `usage_boost` | every search on a registry with an intent graph attached; `intent: null` means no cluster matched | SDK | | `usage_model_mismatch` | the intent graph's centroids were built under a different embedding model: the usage arm pauses, base ranking is unaffected | SDK | A single agent call often records several of these: `search_capabilities` produces both a core-level `search` (or `skill_search`) and a `gateway_search`, and `invoke_tool` produces `invoke_start` / `invoke_end` (or `invoke_error`) plus `gateway_invoke` / `gateway_error`, with `upstream_invoke` / `upstream_error` on top for proxied MCP tools. The four `auth_*` events come only from [Ratel Local OAuth upstreams](https://www.ratel.sh/docs/local/oauth), which embeds the SDK and records into the same stream; the SDKs never emit them. ## The OpenTelemetry span vocabulary Independently of the local sink, the SDK emits OpenTelemetry spans around the same funnel: `gen_ai.*` attributes pinned to semconv v1.42.0, plus the Ratel-owned `ratel.*` overlay. The local event and the span are two independent channels around the same call. | Span | Emitted around | Attributes | | ------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ratel.search` | every `ToolCatalog.search` / `SkillCatalog.search`, including the one inside `search_capabilities` | `ratel.search.target` (`tool` \| `skill`), `ratel.origin`, `ratel.search.top_k`, `ratel.search.hit_count`; `ratel.search.query` only when [content capture](#content-capture) selects a span mode | | `execute_tool ` | every `ToolCatalog.invoke`, including via `invoke_tool` | `gen_ai.operation.name = "execute_tool"`, `gen_ai.tool.name`, `ratel.tool.args_size_bytes`; gated: `gen_ai.tool.call.arguments`, `gen_ai.tool.call.result` | | `ratel.skill.load` | `SkillCatalog.invoke`, including via `get_skill_content` | `ratel.skill.id` | | `ratel.upstream.register` | `registerMcpServer` / `register_mcp_server` (connect + list + ingest) | `ratel.upstream.server`, `ratel.upstream.transport`, `ratel.upstream.tool_count` | | `ratel.auth.flow` | `invoke_tool` hitting an unauthorized (401) upstream | `ratel.auth.outcome = "needs_auth"`, plus `ratel.upstream.server` when known | Tool invocations deliberately reuse `execute_tool`, the standard `gen_ai.operation.name` value, instead of a bespoke `ratel.invoke` span, so a generic OTel backend already understands them. Errors use the standard span status `ERROR` plus the exception event, not a bespoke attribute. The SDK does **not** emit the LLM-call spans (`chat `): those come from your LLM instrumentation, and Ratel's spans join the same trace next to them. ### No-op until a provider Instrumentation is free until you opt in, in both languages: * **TypeScript:** the SDK's only OpenTelemetry dependencies are the API-only `@opentelemetry/api` and `@opentelemetry/api-logs`, never the OTel SDK. Until the host registers providers, every span is a no-op `NonRecordingSpan`, every EventRecord is dropped, and the local trace stream is untouched. * **Python:** the base `ratel-ai` install depends only on `ratel-ai-telemetry>=0.1.3`, the OTel-free vocabulary — still no OpenTelemetry. If `opentelemetry` is missing, every telemetry helper is a straight pass-through, zero overhead, no spans. With it installed, spans and EventRecords go to whatever providers are registered. ### Greenfield: ship spans to Ratel Cloud No OpenTelemetry in your stack yet? In Python, one call at startup registers global tracer and logger providers with batch OTLP exporters pointed at Ratel Cloud. TypeScript has no Ratel bootstrap: the host builds and registers the providers itself. **Python** The exporter ships behind the `[otlp]` extra (without it, `configure_telemetry` raises `ModuleNotFoundError` with this install hint): ```bash pip install 'ratel-ai[otlp]' ``` ```python import os from ratel_ai import configure_telemetry handle = configure_telemetry( endpoint=os.environ.get("RATEL_OTLP_ENDPOINT", "https://cloud.ratel.sh/v1/traces"), api_key=os.environ["RATEL_API_KEY"], ) # ... run your agent: every ratel.* / execute_tool span (and gated EventRecord) now exports ... handle.shutdown() # flush both exporters on exit (handle.force_flush() mid-run) ``` `configure_telemetry(*, api_key=None, endpoint=None, logs_endpoint=None, headers=None, service_name=None, capture_content=None, include_span_and_events=None, export_all_spans=False)` returns a per-call shutdown handle (`handle.shutdown()` / `handle.force_flush()`). It wires both a trace and a Logs exporter; `logs_endpoint` defaults to the sibling `/v1/logs` URL, and `export_all_spans=True` lifts the default `gen_ai.*`/`ratel.*` span filter. **TypeScript** The SDK ships no provider wiring — build the providers with the standard OTel SDK packages and register them before constructing `ratel()`: ```bash npm install @opentelemetry/sdk-node @opentelemetry/sdk-trace-base @opentelemetry/sdk-logs @opentelemetry/exporter-trace-otlp-proto @opentelemetry/exporter-logs-otlp-proto ``` ```ts import { NodeSDK } from "@opentelemetry/sdk-node"; import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base"; import { BatchLogRecordProcessor } from "@opentelemetry/sdk-logs"; import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto"; import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto"; const endpoint = process.env.RATEL_OTLP_ENDPOINT ?? "https://cloud.ratel.sh/v1/traces"; const headers = { Authorization: `Bearer ${process.env.RATEL_API_KEY}` }; const sdk = new NodeSDK({ spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter({ url: endpoint, headers }))], logRecordProcessors: [ new BatchLogRecordProcessor({ exporter: new OTLPLogExporter({ url: endpoint.replace("/v1/traces", "/v1/logs"), headers }), }), ], }); sdk.start(); // ... run your agent: every ratel.* / execute_tool span (and gated EventRecord) now exports ... await sdk.shutdown(); // flush stays with you — the host owns the providers ``` Both lists matter: with `spanProcessors` alone, `NodeSDK` builds the logger provider from the environment and the gated EventRecords land on the default OTLP endpoint, not yours. Endpoint and auth resolution is Python-only — in TypeScript the host owns both entirely (as above): * The `endpoint=` option wins; otherwise `RATEL_OTLP_ENDPOINT`; otherwise the superseded `RATEL_URL`, which still works but raises a `DeprecationWarning` (it also names the catalog source, so it no longer doubles as the OTLP destination). Either way it is the **full OTLP traces URL including `/v1/traces`**. With none set, the call fails before anything registers. * The Logs exporter derives its URL by swapping `/v1/traces` for `/v1/logs`; `logs_endpoint=` overrides it. * The API key is sent as `Authorization: Bearer ` on top of any extra `headers`. When omitted it falls back to the `RATEL_API_KEY` env var (unless you already set an `Authorization` header explicitly). * `service_name` defaults to `"ratel"`. Runnable examples live in the repo: [`examples/telemetry-ts`](https://github.com/ratel-ai/ratel/tree/main/examples/telemetry-ts) and [`examples/telemetry-python`](https://github.com/ratel-ai/ratel/tree/main/examples/telemetry-python) emit a sample `ratel.search` + `execute_tool` trace to a console exporter, no collector or API key needed, and switch to a real OTLP export when `RATEL_OTLP_ENDPOINT` is set — the TypeScript one also emits a `ratel.search.results` EventRecord through a `LoggerProvider`. > Python's `configure_telemetry` owns the global providers. If a tracer **or** logger provider is already registered it fails loudly, pointing at the processors, rather than silently no-op'ing. That error means you are on the wrong path: dual-export instead. ### Already on OpenTelemetry: dual-export OpenTelemetry's coexistence model is one provider per signal, many processors. If Langfuse, the Vercel AI SDK, or your own collector already owns the providers, the SDK's telemetry is already flowing to them; add processors that send a filtered copy to Ratel Cloud. Python ships them packaged; in TypeScript you build your own: **Python** ```python import os from opentelemetry import _logs, trace from opentelemetry.sdk._logs import LoggerProvider from opentelemetry.sdk.trace import TracerProvider from ratel_ai_telemetry.otlp import ratel_log_record_processor, ratel_span_processor endpoint = os.environ.get("RATEL_OTLP_ENDPOINT", "https://cloud.ratel.sh/v1/traces") api_key = os.environ["RATEL_API_KEY"] provider = trace.get_tracer_provider() # the provider your existing setup registered if not isinstance(provider, TracerProvider): raise RuntimeError("register your OpenTelemetry SDK provider before adding processors") provider.add_span_processor(ratel_span_processor(endpoint=endpoint, api_key=api_key)) # The Logs half — without it, captured content never reaches Cloud. logger_provider = LoggerProvider() logger_provider.add_log_record_processor( ratel_log_record_processor(endpoint=endpoint, api_key=api_key) ) _logs.set_logger_provider(logger_provider) ``` `ratel_span_processor(*, api_key=None, endpoint=None, headers=None, span_filter=None, enabled=True)`; `ratel_log_record_processor(...)` takes the same options plus `logs_endpoint` and `log_filter`, deriving the sibling `/v1/logs` URL by default. `ratel_span_exporter(...)` / `ratel_log_exporter(...)` are the bare OTLP exporters if you wire your own processors. **TypeScript** There is no packaged processor — hand-roll the filter onto a batch processor over your own OTLP exporter: ```ts import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto"; import { BatchSpanProcessor, type ReadableSpan } from "@opentelemetry/sdk-trace-base"; import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"; import { LangfuseSpanProcessor } from "@langfuse/otel"; const ratelSignal = (span: ReadableSpan) => span.name.startsWith("ratel.") || Object.keys(span.attributes).some((k) => k.startsWith("gen_ai.") || k.startsWith("ratel.")); class RatelSpanProcessor extends BatchSpanProcessor { onEnd(span: ReadableSpan) { if (ratelSignal(span)) super.onEnd(span); } } const provider = new NodeTracerProvider({ spanProcessors: [ new LangfuseSpanProcessor(), // Langfuse keeps every span new RatelSpanProcessor( // Ratel takes gen_ai.*/ratel.* only new OTLPTraceExporter({ url: process.env.RATEL_OTLP_ENDPOINT ?? "https://cloud.ratel.sh/v1/traces", headers: { Authorization: `Bearer ${process.env.RATEL_API_KEY}` }, }), ), ], }); provider.register(); ``` For captured content, mirror the pattern on a `LoggerProvider`: a `BatchLogRecordProcessor` over an `OTLPLogExporter` at the sibling `/v1/logs` URL, forwarding records whose event name starts with `gen_ai.` or `ratel.` (the [greenfield tab](#greenfield-ship-spans-to-ratel-cloud) shows the Logs wiring). The filter is the point — forward a span if its name starts with `ratel.` or any attribute key starts with `gen_ai.` or `ratel.`, so the framework's `ai.*` wrapper noise stays out: * In Python that predicate is the packaged default of `ratel_span_processor`; `ratel_log_record_processor`'s default `ratel_event_filter` does the same for EventRecords, forwarding only those named `gen_ai.*` / `ratel.*`. In TypeScript you own the predicate (above). * Pass `span_filter=lambda _s: True` (Python) — or drop the filter from your own processor (TypeScript) — to forward everything. Do that, or tail-sample, when full-trace fidelity matters: per-span filtering can orphan the AI SDK's `ai.*` wrapper span from its `gen_ai.*` child. * Python's packaged exporters carry no resource; your provider keeps ownership of `service.name`. ## Content capture Message and tool content never leaves the process by default. Set the mode in code (`configure_telemetry(capture_content=...)` in Python; `setContentCapture(mode)` in TypeScript, re-exported from `@ratel-ai/sdk`) or through the ecosystem instrumentation flag `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT`, the fallback when nothing is set in code; parsing is trimmed and case-insensitive. | Value | Mode | | -------------------------------------- | --------------------------------------------------------------------------------------------- | | unset / empty / `NO_CONTENT` | no content (the default) | | `SPAN_ONLY` | content on span attributes | | `EVENT_ONLY` | content on OpenTelemetry Logs `EventRecord`s only (the Logs Event API), never span attributes | | `SPAN_AND_EVENT` | both | | `TRUE` / `1` (legacy boolean) | `SPAN_AND_EVENT` | | anything else (including `FALSE`, `0`) | no content | The four modes select two independent channels: * **Span attributes** (`SPAN_ONLY` / `SPAN_AND_EVENT`): `ratel.search.query` on `ratel.search`, `gen_ai.tool.call.arguments` / `gen_ai.tool.call.result` on `execute_tool`. * **Logs EventRecords** (`EVENT_ONLY` / `SPAN_AND_EVENT`): a `ratel.tool.execution.details` record carrying structured `gen_ai.tool.call.arguments` and, on success, `gen_ai.tool.call.result` — the Python SDK records only the stable MCP `CallToolResult` fields (`content`, `structuredContent`, `isError`) — plus a `ratel.search.results` record carrying `ratel.search.query`. These need a registered Logs provider; **`EVENT_ONLY` still puts nothing on span attributes**. The `gen_ai.client.inference.operation.details` event stays your LLM instrumentation's convention for message content (`gen_ai.system_instructions`, `gen_ai.input.messages`, `gen_ai.output.messages`). Output messages require a `finish_reason`, so a tool result is never encoded there. ## Next steps [Telemetry (TypeScript)](https://www.ratel.sh/docs/typescript/telemetry) Set up this trace stream in a Node agent. [Telemetry (Python)](https://www.ratel.sh/docs/python/telemetry) The same setup, idiomatic Python. [Framework integrations](https://www.ratel.sh/docs/typescript/framework-integrations) Wire the capability tools into the agent framework you already run. TypeScript shown; the Python twin mirrors it. [Ratel Local statusline](https://www.ratel.sh/docs/local/statusline-telemetry) Operate the local JSONL stream and Claude Code statusline. --- # Self-improving agent Source: https://www.ratel.sh/docs/self-improvement BM25 and dense retrieval score text similarity only — they have no memory. Some gaps no description rewrite can close: `"why is the build broken"` ranks `docker_build` first on the token *build*, even when every such query ends in `gh_run_list`. **Adaptive usage ranking** closes that gap with real usage ([ADR 0014](https://github.com/ratel-ai/ratel/blob/main/docs/adr/0014-adaptive-usage-ranking.md)). A search followed by an invoke becomes a weighted edge in an in-memory `IntentGraph`; when a later query matches one of the graph's clusters, the capabilities that history supports get a boost. > Experimental and off by default. The entry points carry an `experimental` prefix and may change until the capability graduates. `IntentGraph` and its wire form are stable `protocol/v1`. ## How the boost works * Matched clusters add a sub-unit RRF arm fused beside the BM25/dense arms. The arm never overrides a strong lexical/dense match — it lifts capabilities usage history supports, and cannot conjure ones the base ranker did not retrieve. * A query that matches no cluster ranks exactly as if no graph were attached. * Every hit now carries `rank` (0-based position — order and threshold on it) and `fused` (whether `score` switched to an RRF scale). Treat `score` as a within-list hint. * Any method learns: a BM25 catalog clusters queries lexically, reaching repeats and near-repeats; a [semantic or hybrid](https://www.ratel.sh/docs/retrieval-and-search#semantic-and-hybrid-search) catalog clusters by meaning, at no extra embedding cost. ## Turn it on Attach a graph and the catalog learns from every search that leads to an invoke — a normal agent loop is all the training there is. Pass the **same** graph to your tool and skill catalogs so both learn into one set of clusters. **Python** ```python from ratel_ai import IntentGraph graph = IntentGraph() catalog.experimental_enable_adaptive_ranking(graph) # The graph listens to each search and the invoke that follows it. catalog.search("why is the build broken", 5) await catalog.invoke("gh_run_list", {}) ``` **TypeScript** ```ts import { IntentGraph } from "@ratel-ai/sdk"; const graph = new IntentGraph(); catalog.experimentalEnableAdaptiveRanking(graph); // The graph listens to each search and the invoke that follows it. catalog.search("why is the build broken", 5); await catalog.invoke("gh_run_list", {}); ``` `experimental_disable_adaptive_ranking()` / `experimentalDisableAdaptiveRanking()` turns the arm off; the graph keeps what it learned. ## Persist the learning The graph lives in memory and you own storage: serialize to the `protocol/v1` JSON wire form and write it wherever you keep state — file, DB, blob. **Python** ```python saved = graph.to_json() # A later process: reload, attach, keep learning. restored = IntentGraph.from_json(saved) catalog.experimental_enable_adaptive_ranking(restored) ``` **TypeScript** ```ts const saved = graph.toJson(); // A later process: reload, attach, keep learning. const restored = IntentGraph.fromJson(saved); catalog.experimentalEnableAdaptiveRanking(restored); ``` * `graph.rev` is a monotonic write counter. Save only when it moved since your last write, and compare it to a stored graph's `rev` before overwriting to detect a concurrent writer — single-writer is the supported model. * A saved graph contains the raw text of past queries. Treat it like your telemetry log: restrict permissions, keep it out of version control. ## Changing the embedding model Cluster centroids are tied to the model that built them. Load a graph under a different embedding model and the usage arm **pauses** — base ranking is untouched — rather than compare across incompatible vector spaces. (There is also a one-time stderr warning; suppress it with `warn_on_model_mismatch=False` / `warnOnModelMismatch: false`.) **Python** ```python catalog.experimental_adaptive_ranking_status # "paused: model mismatch" await catalog.experimental_rebuild_intent_graph() # re-embed under the current model ``` **TypeScript** ```ts catalog.experimentalAdaptiveRankingStatus.status; // "paused: model mismatch" await catalog.experimentalRebuildIntentGraph(); // re-embed under the current model ``` The rebuild re-embeds the clusters' members; support and edges survive, only the centroids move. For zero-touch recovery, enable with `rebuild_on_model_change=True` (`{ rebuildOnModelChange: true }`): the next dense search re-embeds a paused graph before ranking. Off by default — a rebuild is an embedding pass: cost, possible failure, and a graph mutation. ## Try it * [`examples/adaptive-ranking-python`](https://github.com/ratel-ai/ratel/tree/main/examples/adaptive-ranking-python) — the full loop over BM25: learn, persist, reload, `rev`-gated saves. No model or API key. * [`examples/adaptive-ranking-ts`](https://github.com/ratel-ai/ratel/tree/main/examples/adaptive-ranking-ts) — the same demo in TypeScript. ## Next steps [Retrieval and search](https://www.ratel.sh/docs/retrieval-and-search) How BM25, dense, and now the usage arm rank and fuse — and what `rank` and `fused` mean on a hit. [@ratel-ai/sdk reference](https://www.ratel.sh/docs/packages/typescript/sdk) The full `experimental*` adaptive-ranking API and `IntentGraph` on the TypeScript catalogs. [ratel-ai reference](https://www.ratel.sh/docs/packages/python/ratel-ai) The same surface in Python: `experimental_enable_adaptive_ranking` and friends. --- # Managing chat history Source: https://www.ratel.sh/docs/chat-history **Coming soon.** Compaction will keep long-running agent context lean: trimming what the model no longer needs while keeping it retrievable. Until then, the biggest context lever is retrieval itself: start with [Keyword search](https://www.ratel.sh/docs/retrieval-and-search#keyword-search). --- # Agent memory Source: https://www.ratel.sh/docs/agent-memory **Coming soon.** Agent memory will persist facts across sessions and surface them through the same retrieval pipeline that ranks tools and skills: no separate store, no bespoke recall. Until then, [skills](https://www.ratel.sh/docs/tools-mcp-skills) are the durable-knowledge surface: register a playbook once and the agent retrieves it on demand. --- # Architecture Source: https://www.ratel.sh/docs/architecture Ratel separates **what your agent can do** from **what the model must read right now**. Your Ratel application owns the full catalog. The model gets a small view selected for the current request and a controlled way to reach the rest. ## The pieces Five layers, all in your process: * **Rust engine.** [`ratel-ai-core`](https://www.ratel.sh/docs/packages/rust/ratel-ai-core) owns the registries (`ToolRegistry`, `SkillRegistry`), the three retrieval engines (BM25, semantic over a configurable [embedding model](https://www.ratel.sh/docs/embedding-models) (a local `bge-small-en-v1.5` by default), hybrid fused with Reciprocal Rank Fusion), the opt-in [adaptive usage-ranking](https://www.ratel.sh/docs/self-improvement) read model (an in-memory `IntentGraph`, [ADR-0014](https://github.com/ratel-ai/ratel/blob/main/docs/adr/0014-adaptive-usage-ranking.md)) fused as an extra arm beside those engines, and the local trace stream. * **SDK bindings.** [`@ratel-ai/sdk` (napi) and `ratel-ai` (PyO3)](https://www.ratel.sh/docs/sdks) are thin, mirrored bindings over that same engine: in-process, no server, no API key. * **Catalogs.** [`ToolCatalog` and `SkillCatalog`](https://www.ratel.sh/docs/tools-mcp-skills) pair metadata with executable handlers and hold local tools, MCP server tools, and skills as separate corpora, so tool and skill rankings never crowd each other out. In TypeScript, one [`ratel()`](https://www.ratel.sh/docs/typescript/quickstart) core wraps both — `r.tools` and `r.skills`; Python constructs the catalogs directly. * **Retrieval pipeline.** [BM25](https://www.ratel.sh/docs/retrieval-and-search#keyword-search) is the deterministic default. [Semantic and hybrid](https://www.ratel.sh/docs/retrieval-and-search#semantic-and-hybrid-search) are opt-in per catalog or per call, still against a local index. * **Telemetry stream.** Every search, expansion, and invocation emits a trace event into a sink you configure, off by default, like [OpenTelemetry export](https://www.ratel.sh/docs/observability), which speaks the [`ratel.*` span and event vocabulary](https://www.ratel.sh/docs/packages/rust/ratel-ai-telemetry). ## What happens on a turn ```text Ratel SDK user request → local retrieval → top-K tools + capability tools → model ↕ ToolCatalog ``` [Ratel Local](https://www.ratel.sh/docs/local) exposes the same catalog semantics and wire contract over MCP, either as its CLI-managed gateway or [embedded in a Node process](https://www.ratel.sh/docs/local/library). ### Register once A tool enters the catalog with an id, a retrievable name and description, JSON input and output schemas, and an executor. MCP imports produce the same shape automatically. Skill metadata lives in the separate `SkillCatalog`. ### Retrieve locally For the incoming request, Ratel ranks the catalog in-process. BM25 is the deterministic default. Semantic and hybrid retrieval are opt-in and still use a local index rather than a hosted vector database. ### Give the model a small starting set An SDK integration can inject the top-K matches as normal framework tools. The capability tools stay available alongside them. A Ratel Local integration begins with the capability tools because the upstream catalog remains behind the MCP boundary. ### Expand only when needed If the starting set is insufficient, the model calls `search_capabilities` and gets separately ranked tool and skill hits, each with the id and schema needed for the next call. A matched skill exposes its body only through `get_skill_content`. See [Progressive disclosure tools](https://www.ratel.sh/docs/progressive-disclosure-tools) for the exact contracts. ### Invoke by id The model calls `invoke_tool` with the selected `toolId` and nested `args`. Ratel dispatches to the local handler or upstream MCP server and returns the result to the normal agent loop. Tool permissions, authentication, and side effects still belong to the tool and its host; retrieval does not bypass them. ## What stays out of the prompt * Definitions for tools that did not match the current request. * Full skill bodies until the agent chooses to load one. * Upstream MCP inventories when Ratel Local is the only server exposed to the host. * Retrieval infrastructure: the default index runs locally with no Ratel service call. What remains is ordinary agent behavior: your model provider still handles the model request, and invoked tools keep the permissions you configured. ## Keep building with Ratel [Build the SDK path](https://www.ratel.sh/docs/quickstart) Let a coding agent integrate Ratel, or follow the TypeScript and Python quickstarts. [What this saves](https://www.ratel.sh/docs/optimization#efficiency) The token and accuracy cost of oversized tool lists, and what this architecture buys back. > **Using Claude Code, Codex, or Cursor?** > > Claude Code and Codex can [import or link](https://www.ratel.sh/docs/local/import-or-link) through Ratel Local. > Cursor uses [manual MCP configuration](https://www.ratel.sh/docs/local/cursor). Both paths end at the same > capability tools. --- # Tools, MCP & Skills Source: https://www.ratel.sh/docs/tools-mcp-skills Ratel manages three kinds of capabilities: **local tools** (functions in your code), **MCP server tools** (from upstream servers), and **skills** (Markdown playbooks). They live in two catalogs (`ToolCatalog` for tools, `SkillCatalog` for skills) behind one contract; in TypeScript, one `ratel()` core holds both — `r.tools` and `r.skills`. Everything is registered once, ranked per turn by `search_capabilities`, and loaded on demand. See [Architecture](https://www.ratel.sh/docs/architecture) for how the pieces fit. ## Tools & MCP servers A local tool is a schema plus a handler you register directly. An upstream MCP server contributes its tools through the same path. Both land in the same `ToolCatalog` and rank as **one surface**: `search_capabilities` scores them together, and `invoke_tool` runs whichever wins. The agent never cares where a tool came from. See Register tools ([TypeScript](https://www.ratel.sh/docs/typescript/register-tools), [Python](https://www.ratel.sh/docs/python/register-tools)) for the how. ## Skills > This section is the **concept**: reusable playbooks your agent discovers at runtime. Ratel's own audit and engagement skills for coding agents ship with the [Skillset](https://www.ratel.sh/docs/skillset). A **skill** is a reusable Markdown playbook (a deploy runbook, a debugging checklist) registered with ranked metadata so an agent finds it at the moment it matters. Where a tool is executed, a skill is **read, not executed**: the body is the payload. The agent discovers a skill through `search_capabilities` and pulls the full body into context with `get_skill_content`, two of the three [progressive disclosure tools](https://www.ratel.sh/docs/progressive-disclosure-tools). Then it follows the instructions. ### Skills vs tools A tool is one operation: the model fills in arguments, the runtime executes. A skill is instructions: a multi-step procedure the agent carries out itself. When the knowledge is "how to do this well" rather than "one operation to run", instructions beat schemas. [ADR-0005](https://github.com/ratel-ai/ratel/blob/main/docs/adr/0005-first-class-skills.md) makes two properties load-bearing: * **Read, not executed.** Nothing runs when a skill loads. * **Advisory.** A task completes without the skill, just worse, so surfacing fails silently and must be measurable. The SDK emits `skill_search` and `skill_invoke` trace events; see [Telemetry](https://www.ratel.sh/docs/observability). The advisory property is why there is no standalone skill-search tool. The agent must search for tools anyway, so skills ride in the same `search_capabilities` response: the tool's necessity carries the skill. ADR-0005 rejected the alternatives: separate skill-search (skippable), one merged ranked list (starvation, incomparable scores), and a related-skills side-channel (a redundant third mechanism). ### The skill shape A skill is `{ id, name, description, tags, tools, metadata, body }`. Only `id`, `name`, and `description` are required; the rest defaults to empty in both SDKs. | Field | Required | Indexed for ranking | Purpose | | ------------- | -------- | ------------------- | ----------------------------------------------------------------------------------------------- | | `id` | yes | no | lookup key for `get_skill_content` | | `name` | yes | yes | ranking signal, matched whole and identifier-split | | `description` | yes | yes | ranking signal; shown in the `skills` bucket, compacted to 160 chars | | `tags` | no, `[]` | yes | author-declared labels and task phrases ("frontend", "login form") folded into the indexed text | | `tools` | no, `[]` | no | ids of tools the playbook calls; pulled into the `tools` bucket when the skill matches | | `metadata` | no, `{}` | no | free-form context for higher layers, e.g. `{"stacks": ["react"]}`; never matched as query terms | | `body` | no, `""` | no | the Markdown payload returned by `get_skill_content` | Worth memorizing: **tags are query terms**; **metadata is carried context**, never matched. `body` is deliberately excluded from the index, so a 15 KB playbook never skews relevance. ### A separate corpus, an independent budget `SkillCatalog` is the on-demand analog of `ToolCatalog`: register once, search ranks by relevance, the body is fetched only on load. Its separate corpus ranks `name`, `description`, and `tags`: [BM25](https://www.ratel.sh/docs/retrieval-and-search#keyword-search) by default, [semantic or hybrid](https://www.ratel.sh/docs/retrieval-and-search#semantic-and-hybrid-search) when enabled. `search_capabilities` returns two independently ranked buckets, `tools` and `skills`, each with its own top-K budget (`topKSkills` defaults to 3, `topKTools` to 5, each capped at 50). Scores are never compared across the two corpora, so a relevant skill can never be crowded out by a pile of matching tools. ### Declared tools ride in When a skill matches, `search_capabilities` also pulls its declared `tools` into the `tools` bucket, playbook and toolkit in one turn. The mechanics: * **Additive**: pulled-in tools do not count against `topKTools`. * **Score 0**: a query-matched tool keeps its score; a skill-declared dependency rides in at 0. * **Deduped**: a tool that matched both ways appears once. * **Must exist in the `ToolCatalog`**: unknown declared ids are silently skipped. ### Wiring In TypeScript, `ratel()` wires this for you: register on `r.skills`, and the `search_capabilities` and `get_skill_content` from `r.modelTools()` share the core's skill catalog — the `skills` bucket is always advertised, regardless of registration order. Assembling the funnel from the [factories](https://www.ratel.sh/docs/progressive-disclosure-tools#constructing-the-capability-tools) instead (the only path in Python)? Pass the `SkillCatalog` as the second argument to the search factory, and hand the agent `get_skill_content` built from the same catalog. Without it, the `skills` bucket is always `[]`. On that path, register skills before building the search tool: by default its description advertises the `skills` bucket and `get_skill_content` only when the catalog is non-empty at construction time. In TypeScript (0.5.1+), `SearchCapabilitiesOptions.advertiseSkills` pins the clause on or off explicitly — `ratel().modelTools()` uses it so the exposed set never depends on registration order. **Python** ```python import asyncio from ratel_ai import Skill, SkillCatalog, ToolCatalog, get_skill_content_tool, search_capabilities_tool catalog = ToolCatalog() # ...register tools... skills = SkillCatalog() asyncio.run(skills.register( Skill( id="vercel-deploy", name="vercel-deploy", description="How to deploy to Vercel: env vars, preview vs production, rollbacks.", tags=["deploy", "ship to production"], # indexed: query terms tools=["vercel__deploy", "fs__read_file"], # dependency edge, not indexed metadata={"stacks": ["next", "vercel"]}, # carried context, never matched body="## Deploying to Vercel\n1. ...", ) )) search = search_capabilities_tool(catalog, skills) # 2nd argument: skills bucket is live load = get_skill_content_tool(skills) # id: "get_skill_content" ``` **TypeScript** ```ts import { ratel } from "@ratel-ai/sdk"; const r = ratel(); // ...register tools on r.tools... await r.skills.register({ id: "vercel-deploy", name: "vercel-deploy", description: "How to deploy to Vercel: env vars, preview vs production, rollbacks.", tags: ["deploy", "ship to production"], // indexed: query terms tools: ["vercel__deploy", "fs__read_file"], // dependency edge, not indexed metadata: { stacks: ["next", "vercel"] }, // carried context, never matched body: "## Deploying to Vercel\n1. ...", }); // Both tools share the core's skill catalog: the skills bucket is live. const { search_capabilities, get_skill_content } = r.modelTools(); ``` Ratel Local loads skills from disk and manages them from the CLI. See [Managing skills](https://www.ratel.sh/docs/local/managing-skills). ### How the agent uses a skill #### `search_capabilities` surfaces the skill The user says "ship this to production" and no deploy tool is in context. The agent searches; the skill ranks in the `skills` bucket, and its declared tools join the `tools` bucket at score 0: ```jsonc { "tools": { "groups": [ { "server": { "name": "vercel" }, "hits": [{ "toolId": "vercel__deploy", "score": 0, "description": "...", "inputSchema": {} }] }, { "server": { "name": "fs" }, "hits": [{ "toolId": "fs__read_file", "score": 0, "description": "...", "inputSchema": {} }] } ] }, "skills": [ { "skillId": "vercel-deploy", "score": 1.42, "description": "How to deploy to Vercel: env vars, preview vs production, rollbacks." } ] } ``` #### `get_skill_content` pulls the body `get_skill_content({ "skillId": "vercel-deploy" })` returns `{ "body": "## Deploying to Vercel\n1. ..." }`. An unknown id returns a structured `{ error, isError: true }` pointing back to `search_capabilities`, never a crash. #### The agent follows the playbook The full Markdown is now in context. The agent works through the steps, running the tools via `invoke_tool`. Loading the skill executed nothing; the model does the work. ## Next steps [Register tools (TypeScript)](https://www.ratel.sh/docs/typescript/register-tools) Wire local functions and MCP servers into `r.tools`. [Register tools (Python)](https://www.ratel.sh/docs/python/register-tools) The same registration flow, idiomatic Python. [Progressive disclosure tools](https://www.ratel.sh/docs/progressive-disclosure-tools) The full contract of `search_capabilities`, `invoke_tool`, and `get_skill_content`. [Keyword search](https://www.ratel.sh/docs/retrieval-and-search#keyword-search) How BM25 ranks both catalogs and how to write retrievable definitions. --- # Progressive disclosure tools Source: https://www.ratel.sh/docs/progressive-disclosure-tools Three **capability tools** give the model self-service access to the whole catalog: `search_capabilities` finds by intent, `invoke_tool` runs a hit by id, `get_skill_content` loads a skill's playbook. This page is their contract. Their descriptions and JSON schemas are a product contract shown to the model, kept verbatim across the Python and TypeScript SDKs. [Ratel Local](https://www.ratel.sh/docs/local) registers these same objects as its MCP tools. Parameter and result keys are camelCase in both languages (`toolId`, `topKTools`, `inputSchema`). The wire contract is language-neutral. ## `search_capabilities` One natural-language query returns **two independently-ranked buckets**: `tools` (executable, run one via `invoke_tool`) and `skills` (reusable playbooks, load one via `get_skill_content`). Each bucket has its own result budget and corpus, ranked with that catalog's selected method: [BM25 by default](https://www.ratel.sh/docs/retrieval-and-search#keyword-search), [semantic and hybrid](https://www.ratel.sh/docs/retrieval-and-search#semantic-and-hybrid-search) opt-in. A relevant skill is never crowded out by matching tools, and scores are never compared across the two. | Parameter | Type | Required | Behavior | | ------------ | ------- | -------- | ------------------------------- | | `query` | string | yes | describe what you want to do | | `topKTools` | integer | no | max tools to return, default 5 | | `topKSkills` | integer | no | max skills to return, default 3 | Top-K values must be positive integers, capped at 50. Anything else (missing, 0, negative, fractional) falls back to the default: a stray value can never return zero results or an unbounded set. ```jsonc { "tools": { "groups": [ { // grouped by server: the toolId prefix before the first "__" "server": { "name": "fs", "description": "filesystem helpers" }, "hits": [ { "toolId": "fs__read_file", "score": 1.87, // BM25 score of the query match (illustrative) "description": "Read a file from local disk.", "inputSchema": { "properties": { "path": { "type": "string", "description": "path to read" } } } } ] } ] }, "skills": [ { "skillId": "vercel-deploy", "score": 1.42, "description": "How to deploy to Vercel: env vars, preview vs production, rollbacks." } ] } ``` * Both keys are always present: no skill catalog wired means `skills` is `[]`; no tool matches means `tools.groups` is `[]`. * Hits are grouped by server, in first-hit order. A hit's server is its `toolId` prefix before the first `"__"`; an id with no separator is its own group. * Each hit carries exactly `toolId`, `score`, `description` (the raw registered description), and `inputSchema`. Skill hit descriptions are compacted: whitespace collapsed, clipped to 160 characters on a word boundary. * A matched skill's declared `tools` are pulled into the tools bucket at `score: 0` (additive beyond `topKTools`, deduped against query hits), so the agent gets a playbook and its toolkit in one turn. See [Tools, MCP & Skills](https://www.ratel.sh/docs/tools-mcp-skills). ## `invoke_tool` Runs a catalog tool by id: `catalog.invoke(toolId, args)` under the hood. | Parameter | Type | Required | Behavior | | --------- | ------ | -------- | --------------------------------------------------------------------------- | | `toolId` | string | yes | id of the tool to invoke | | `args` | object | yes | the tool's arguments, matching its `inputSchema`, nested as a single object | The **nested-args contract**: arguments go under `args`, never flattened to the top level. The runtime forgives the common model mistake: when `args` is missing or `null`, the remaining top-level keys (minus `toolId`) become the arguments, though a host that validates the input schema first may reject the flattened form. A non-null `args` that is not an object is a structured error; nothing is forwarded. On success you get the tool's raw result back, untouched, no envelope: ```jsonc // invoke_tool({ "toolId": "fs__read_file", "args": { "path": "/tmp/x" } }) { "contents": "contents of /tmp/x" } ``` `invoke_tool` **never throws to the caller**. Every failure returns `{ "error": "...", "isError": true }`, so a model mistake stays recoverable inside the loop instead of crashing the host: | Failure | Returned `error` | | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Unknown or missing `toolId` | `unknown toolId: . Use the catalog's search tool to discover available ids.` | | Non-object `args` | ``invalid args for : `args` must be an object containing the tool's arguments.`` | | Tool throws `UnauthorizedError` | `needs_auth`, always with a re-auth `hint`; when the upstream server is inferable from the id prefix, an `upstream` key names it and the hint appends its name | | Tool throws anything else | `tool threw: ` | The factory takes an optional callback (`onUnauthorized` in TypeScript, `on_unauthorized` in Python, sync or async) invoked with the upstream server name on the `needs_auth` path. ## `get_skill_content` The read counterpart to `invoke_tool`: skills are read, not executed. The flow: `search_capabilities` surfaces a skill in the `skills` bucket, `get_skill_content` pulls its complete playbook into context, the agent follows it. Bundled scripts or files are referenced by absolute path inside the body. | Parameter | Type | Required | Behavior | | --------- | ------ | -------- | ----------------------- | | `skillId` | string | yes | id of the skill to load | ```jsonc // get_skill_content({ "skillId": "vercel-deploy" }) { "body": "## Deploying to Vercel\n1. ..." } // the full SKILL.md Markdown // unknown or missing skillId: a structured error, never a throw { "error": "unknown skillId: . Use search_capabilities to discover available ids.", "isError": true } ``` The output schema declares `body`, `error`, and `isError` all optional, deliberately. Both shapes must validate as structured content; requiring `body` would turn the error branch into a protocol error instead of the declared structured error. ## Constructing the capability tools In TypeScript, `r.modelTools()` returns all three, wired to the core's shared catalogs — always all three, regardless of registration order. Each call mints fresh objects, so take them once and reuse (prompt cache). Underneath sit three factories: the piecemeal path, and the only path in Python. Each wraps your catalog and returns a plain `ExecutableTool` whose `id` equals its name, ready to hand to your agent loop: **Python** ```python from ratel_ai import ToolCatalog, SkillCatalog, search_capabilities_tool, invoke_tool_tool, get_skill_content_tool catalog = ToolCatalog() skills = SkillCatalog() # ...register tools and skills... search = search_capabilities_tool(catalog, skills) # id: "search_capabilities" invoke = invoke_tool_tool(catalog) # id: "invoke_tool" load = get_skill_content_tool(skills) # id: "get_skill_content" ``` **TypeScript** ```ts import { ratel } from "@ratel-ai/sdk"; const r = ratel(); // ...register tools on r.tools, skills on r.skills... const { search_capabilities, invoke_tool, get_skill_content } = r.modelTools(); ``` Assembling the funnel yourself? The factories take the raw catalogs (`r.tools.catalog` and `r.skills`, or your own): ```ts import { ToolCatalog, SkillCatalog, searchCapabilitiesTool, invokeToolTool, getSkillContentTool } from "@ratel-ai/sdk"; const catalog = new ToolCatalog(); const skills = new SkillCatalog(); // ...register tools and skills... const search = searchCapabilitiesTool(catalog, skills); // id: "search_capabilities" const invoke = invokeToolTool(catalog); // id: "invoke_tool" const load = getSkillContentTool(skills); // id: "get_skill_content" ``` On the factory path, the skill catalog is optional. Pass one with skills registered and the tool's description advertises the `skills` bucket to the model; without one, `skills` is always `[]`. Gating is checked at construction time and an empty catalog counts as no skills. Build the tool after registering, or — in TypeScript (0.5.1+) — pin the clause explicitly with `SearchCapabilitiesOptions.advertiseSkills` (what `ratel().modelTools()` does, so its exposed set never depends on registration order). The search factory also accepts upstream server info (`upstreamServers` / `upstream_servers`), listed in the description so the model knows what the catalog aggregates. Every search the capability tools run is tagged `origin: "agent"` in trace events and OTel spans, so agent-initiated discovery stays distinguishable from host-code calls to `catalog.search`. See [Telemetry](https://www.ratel.sh/docs/observability). > **Upgrading from 0.1.x?** > > `search_tools` (factories `searchToolsTool` / `search_tools_tool`) is the pre-0.2.0 search, still exported as a deprecated compatibility shim with its original behavior: a tools-only `{ "groups": [...] }` result and a single `topK` input. It is not an alias of the two-bucket tool. Migrate to `search_capabilities`; registering both serves the old and new names during a migration window. ## Next steps [Optimize agent capabilities](https://www.ratel.sh/docs/optimization) The use-case view: put everything in a catalog and disclose progressively, plus what it saves. [Recall modes](https://www.ratel.sh/docs/recall-modes) Eager recall vs on-demand search, and why both preserve prompt caching. [Retrieval and search](https://www.ratel.sh/docs/retrieval-and-search) How the buckets are ranked: BM25 keyword search by default, semantic and hybrid when you opt in. [Use the discovery tools (TypeScript)](https://www.ratel.sh/docs/typescript/discovery-tools) Hand these tools to your agent loop and reach the whole catalog from inside a turn. [Use the discovery tools (Python)](https://www.ratel.sh/docs/python/discovery-tools) The same self-service discovery flow, idiomatic Python. --- # Recall modes Source: https://www.ratel.sh/docs/recall-modes Each turn, the model needs the few capabilities that matter now. **Recall** is how they arrive. Both modes go through the same [`search_capabilities` contract](https://www.ratel.sh/docs/progressive-disclosure-tools#search_capabilities); they differ in who runs the first search. ## Eager recall The host searches before the model does. Before each model call, rank the catalog against the **last user message** and append a synthetic `search_capabilities` call/result pair to the message list: ```jsonc // appended after the user's message, in your framework's tool-call message shape [ { "role": "assistant", "content": [{ "type": "tool-call", "toolCallId": "", "toolName": "search_capabilities", "input": { "query": "" } }] }, { "role": "tool", "content": [{ "type": "tool-result", "toolCallId": "", "toolName": "search_capabilities", "output": { "tools": { "groups": [/* top-K hits */] }, "skills": [/* ... */] } }] } ] ``` The model wakes up with the top-K already in context, as if it had searched: no discovery round-trip for the common case. Recall fires only when the last message is a user turn; assistant messages and tool results never re-trigger it. The per-language recipes are on Use the discovery tools ([TypeScript](https://www.ratel.sh/docs/typescript/discovery-tools#eager-recall), [Python](https://www.ratel.sh/docs/python/discovery-tools#eager-recall)). [Ratel Local](https://www.ratel.sh/docs/local)'s [skill preload hook](https://www.ratel.sh/docs/local/managing-skills) is the same idea for Claude Code: rank on every prompt, inject a pointer when one skill clearly wins. ## On-demand search The model searches itself. `search_capabilities` sits in the tool list every turn; the model calls it when the current toolset falls short, then follows with `invoke_tool` or `get_skill_content`. This is the [progressive-disclosure funnel](https://www.ratel.sh/docs/progressive-disclosure-tools) with no host involvement. The two modes compose rather than compete. Eager recall covers the predictable case, and the capability tools stay in the list, so the model still searches on demand for the long tail. ## Both preserve prompt caching Providers cache the prompt as a prefix: tool definitions, system prompt, then messages. A cache hit needs that prefix byte-identical; changing anything invalidates the cache from that point on. Both recall modes only **append**: * The tool list stays the same three capability tools every turn, however large the catalog grows. * Eager recall adds the synthetic pair after the last user message — a suffix append. * On-demand search arrives as an ordinary model tool call and result — also a suffix. Nothing upstream is rewritten, so the cached prefix keeps growing instead of busting. The contrast is the top-K pre-filter ([TypeScript](https://www.ratel.sh/docs/typescript/discovery-tools#assemble-the-per-turn-toolset), [Python](https://www.ratel.sh/docs/python/discovery-tools#assemble-the-per-turn-toolset)): putting each turn's hits into the **tool list** buys the framework's schema validation at the LLM boundary, but tool definitions sit at the top of the prompt, so a changed top-K invalidates the cache behind it. Both are legitimate; measure cached input tokens on your own traffic and pick. ## Telemetry Trace events and the `ratel.origin` span attribute record who ran each search: `catalog.search` from host code is `"direct"`; anything through the capability tools — including an eager-recall call to the search tool's `execute` — is `"agent"`. See [Telemetry](https://www.ratel.sh/docs/observability). ## Next steps [Use the discovery tools (TypeScript)](https://www.ratel.sh/docs/typescript/discovery-tools#eager-recall) The eager-recall recipe in AI SDK message shapes. [Use the discovery tools (Python)](https://www.ratel.sh/docs/python/discovery-tools#eager-recall) The same recipe in Pydantic AI message shapes. [Progressive disclosure tools](https://www.ratel.sh/docs/progressive-disclosure-tools) The contract both modes funnel through: request and result shapes, defaults, errors. --- # Retrieval and search Source: https://www.ratel.sh/docs/retrieval-and-search Every registered tool and skill definition competes for the model's attention. Stack up a few dozen and each turn opens with the model reading definitions it will never call, paying in tokens and accuracy. Ratel keeps the catalog outside the prompt and ranks it per query. The default engine is **BM25** over a schema-aware text projection of each definition: it runs in your process, deterministically: no model, no vector DB, no service. ## Keyword search ### Two ways to consume the ranking Both SDKs expose one retrieval engine through two patterns, and most agents run both at once: * **Pre-filter (top-K).** Before each model call, a host-side search (`r.tools.search(query, topK)` in TypeScript, `catalog.search(query, top_k)` in Python) returns the few tools most relevant to the user's message. Only those enter the tool list. * **Dynamic capability search.** The agent carries `search_capabilities` and `invoke_tool` and reaches the rest of the catalog itself when the pre-filtered set is not enough. See [Progressive disclosure](https://www.ratel.sh/docs/optimization#progressive-disclosure). Whichever path a query arrives through, the ranking below is the same. ### BM25 over a schema-aware projection BM25 is the lexical algorithm behind most search engines. Ratel runs it in-process and caches the index across searches: the first search after a catalog mutation (`register`, or the skills' `replaceAll` / `replace_all`) rebuilds it, and later searches reuse it. Scores are byte-for-byte the same as rebuilding per search ([ADR 0011](https://github.com/ratel-ai/ratel/blob/main/docs/adr/0011-selectable-retrieval-methods.md)) — the cache is purely a latency win. An empty catalog simply returns no hits. The scorer is tuned for short documents like tool definitions (`k1 = 0.9`, `b = 0.4`; fixed, not a knob). Ties break by tool id, so the same query over the same catalog returns the same hits in the same order on any machine. #### What gets indexed BM25 does not rank raw JSON. Each tool is flattened into a **schema-aware text projection** ([ADR 0004](https://github.com/ratel-ai/ratel/blob/main/docs/adr/0004-retrieval-and-tool-selection.md)): 1. The tool **name**, whole and identifier-split: `search_files` also indexes "search files". Hyphens are not split, so prefer `snake_case` or `camelCase` over kebab-case ids. 2. The **description**, verbatim. 3. Every property of the input and output schemas: the property **name** (whole and split), its **description**, and each **string** value of its **enum**, recursing through nested objects and array items. Non-string enum values are skipped. For each [skill](https://www.ratel.sh/docs/tools-mcp-skills), the projection is the `name`, each **tag**, and the `description`. The body is excluded by design: a 15 KB runbook would drown the description's term weights. Structural JSON Schema tokens (`type`, `required`, braces) are never indexed. The emitted text is tokenized at index and query time (lowercased, stemmed, stop words removed), so matching is not verbatim. The consequence is the point: **your vocabulary is the recall mechanism.** The projection is a contract: every retrieval method ranks exactly this text, and changing the flattening is treated as a breaking change. ### Write definitions the index can find Every definition serves two readers. The **index** decides whether it surfaces; the **model** reads the description to decide *when* to call and the schema to decide *how*. * **Say what it does and when to use it.** One sentence for the what, then `Use when ...`. The "when" clause is what separates a tool from its sibling in a shared result set. * **Use the caller's vocabulary.** Stemming matches "refunded" to "refund"; nothing maps "monetary adjustments" to "money back". Add distinct trigger phrasings, not repetitions: term-frequency scoring saturates fast. * **Avoid near-duplicate descriptions.** Two tools described in the same words split the score and the model's confidence. Merge them, or rewrite each "when to use" until they no longer overlap. * **Name parameters and enum values like they will be searched.** `arg1` is invisible to the index; a `status` enum of `["pending", "shipped", "refunded"]` adds query terms *and* constrains the call. * **Keep schemas tight for the model.** Structure is not indexed, but it stops malformed calls: type every property, list `required`, set `additionalProperties: false`. * **Tag your skills.** The body is not indexed, so tags and the description are where an intent prompt has to land. Fold in category labels ("billing") and raw task phrases ("issue a refund"). Anti-patterns: * **Paraphrase chains.** "Searches files. Finds files. Locates files." Saturated terms, still no "when to use". * **Marketing tone.** No caller types "seamless" or "powerful". Concrete verbs and domain nouns only. * **One-line anemia.** "Refund tool." Nothing to match, nothing for the model to decide with. * **Structural noise.** Restating the schema in prose, or documenting internals (transport, timeouts, id formats). Spot-check with a host-side search — `r.tools.search` in TypeScript, `catalog.search` in Python — using realistic queries: the user's phrasing, not the tool's name. The intended tool should top each phrasing you expect to serve. **Python** ```python from ratel_ai import ToolCatalog catalog = ToolCatalog() # ...register the exact tools your agent registers in production. for q in ["refund a customer order", "customer wants money back"]: hits = catalog.search(q, top_k=5) print(q, "->", [(h.tool_id, round(h.score, 2)) for h in hits]) ``` **TypeScript** ```ts import { ratel } from "@ratel-ai/sdk"; const r = ratel(); // ...register the exact tools your agent registers in production. for (const q of ["refund a customer order", "customer wants money back"]) { const hits = r.tools.search(q, 5); console.log(q, "->", hits.map((h) => `${h.toolId}: ${h.score.toFixed(2)}`)); } ``` Skills work the same way — `r.skills.search` in TypeScript, `SkillCatalog.search` in Python; hits carry `skillId` / `skill_id`. > The `ratel-tune-definitions` skill in the [skills suite](https://www.ratel.sh/docs/skillset) > automates this audit: it inventories every definition, flags these failure modes, > and writes a prioritized plan of before/after rewrites. ### When keyword search misses BM25 misses the query that shares no words with the right definition: "summarize this contract" will not surface `extract_document_text` unless the overlap is written in. The first fix is better definitions. When paraphrase queries keep missing, opt into [Semantic and hybrid search](#semantic-and-hybrid-search). ## Semantic and hybrid search Better definitions are the first fix for that kind of miss, but they cannot close every gap: when the query and the right tool genuinely share no vocabulary, two opt-in methods pick up where BM25 leaves off ([ADR 0011](https://github.com/ratel-ai/ratel/blob/main/docs/adr/0011-selectable-retrieval-methods.md)). Both rank the same [indexed projection](#keyword-search) the default engine does; they only change how it is scored. ### The two methods * **`"semantic"`** embeds the query with a local model and cosine-ranks it against pre-built per-tool vectors. * **`"hybrid"`** runs both the BM25 and semantic arms, each to a depth of at least 100, and fuses them with Reciprocal Rank Fusion (`k = 60`, no reranker). RRF fuses rank positions, not raw scores. BM25's unbounded scores and cosine's `[-1, 1]` never need reconciling, which also means scores are not comparable across methods. Opt-in [adaptive usage ranking](https://www.ratel.sh/docs/self-improvement) adds a third arm to the same fusion: when the query matches learned usage, a **usage arm** fuses beside the BM25 and dense arms — on any method, including plain BM25. Because the scale of `score` can switch, every hit carries `rank` (0-based position — the field to order or threshold on) and `fused` (whether `score` is on an RRF scale: `true` on hybrid or when the usage arm fused, `false` for a raw BM25 or cosine score). All three methods rank the same [indexed projection](#keyword-search); embeddings are computed over that text, not raw JSON. ### Enable it per catalog or per call The method is a string: `"bm25"` (default), `"semantic"`, or `"hybrid"`, chosen at catalog construction. The synchronous `search` is BM25-only; `searchAsync` / `search_async` runs any method and takes a per-call override, and the per-call value wins. In TypeScript, `ratel({ method })` forwards the choice to both the tool and skill catalogs. **Python** ```python import asyncio from ratel_ai import ToolCatalog # Per catalog: every search defaults to hybrid. catalog = ToolCatalog(method="hybrid") async def main(): # register() is async and takes one tool or an iterable. On a # semantic/hybrid catalog it embeds the batch, so await it; a # model-load or embedding failure surfaces here as EmbedderError. await catalog.register(tools) # Per call: override the catalog default for one search. return await catalog.search_async("summarize this contract", 5, method="semantic") hits = asyncio.run(main()) ``` **TypeScript** ```ts import { ratel } from "@ratel-ai/sdk"; // Per core: every search defaults to hybrid. const r = ratel({ method: "hybrid" }); // register() is async and variadic — pass the whole batch in one call. // On a semantic/hybrid core it embeds the batch, so await it; a // model-load or embedding failure surfaces here as EmbedderError. await r.tools.register(...tools); // Per call: override the core default for one search. const hits = await r.tools.searchAsync("summarize this contract", 5, "semantic"); ``` `SkillCatalog` takes the same `method` option and per-call override; [skills](https://www.ratel.sh/docs/tools-mcp-skills) rank in their own corpus with the same engines. One consumer never overrides: `search_capabilities` always searches with the catalog's construction-time default, and [Ratel Local](https://www.ratel.sh/docs/local) inherits it the same way. To change the engine your agent uses, change the catalog. ### The embedding cache Semantic and hybrid rank against a pre-built embedding cache. A search only ever embeds the query, never the corpus. A catalog constructed as semantic or hybrid keeps that cache current itself: every `await register(...)` embeds the new or changed tools in one pass, incrementally; adding or re-registering one tool costs one embedding, not N. There is no separate build step and no `buildEmbeddings` — for the tool corpus, `register` is the only way in, so pass a whole batch at once to embed it in a single request. The skill corpus has a second way in: `replaceAll` / `replace_all` swaps in the whole catalog — the batch *is* the corpus, so ids missing from it are removed. The swap itself is synchronous; awaiting the returned `PendingReplace` drives the embedding pass, and unchanged entries cost zero embeddings. Details are on Register skills ([TypeScript](https://www.ratel.sh/docs/typescript/register-skills), [Python](https://www.ratel.sh/docs/python/register-skills)). The BM25 default loads no model and holds no vectors. You cannot retrofit embeddings onto a `"bm25"` catalog. Construct it with `"semantic"` or `"hybrid"` up front. Recovering from a model or dimension change is the same move: build a new catalog and re-register. The per-language walk-throughs, including the typed failure modes, live on Semantic & hybrid search ([TypeScript](https://www.ratel.sh/docs/typescript/semantic-search), [Python](https://www.ratel.sh/docs/python/semantic-search)); the cache lifecycle details are on Register tools ([TypeScript](https://www.ratel.sh/docs/typescript/register-tools#the-embedding-cache), [Python](https://www.ratel.sh/docs/python/register-tools#the-embedding-cache)). > A semantic or hybrid search on a catalog with no embeddings fails fast, with the message `embeddings are not computed for semantic search (hint: embed the corpus before running a semantic/hybrid search)`. The guard loads no model, a search never silently embeds the corpus, and BM25 searches on the same catalog keep working. Model-load, endpoint, auth, and dimension failures surface from `await register(...)` (or `await searchAsync(...)`) as a typed `EmbedderError` carrying a stable `code`; a vector-width change raises its `DimensionMismatchError` subclass. ### The embedding model By default, opting in loads **`BAAI/bge-small-en-v1.5`** (384 dimensions) in the SDK's own process: * **English-only.** `bge-small-en-v1.5` is trained on English; queries in other languages rank poorly. For multilingual retrieval, configure a multilingual [embedding model](https://www.ratel.sh/docs/embedding-models). * **Pure-Rust Candle inference, CPU-only.** No service, no API key, no GPU. * **Pinned HuggingFace revision**, so embeddings are reproducible across machines and over time. * **Downloaded on first use** into the shared HuggingFace cache (`~/.cache/huggingface`), then loaded offline. Nothing downloads at install time or inside a search. * **Roughly 130 MB resident** while loaded. * `HF_HOME` moves the cache; `HF_ENDPOINT` points the download at a mirror for proxied or air-gapped environments. * A cold load slower than 5 s emits an `embedder_load` [trace event](https://www.ratel.sh/docs/observability) with status `slow`, a hint the machine may be underpowered for local inference. `RATEL_EMBED_SLOW_MS` overrides the threshold. The source is configurable: pass an `embedding` spec at catalog construction to select a different HuggingFace model, a local Candle model directory, a local Ollama model, or an OpenAI-compatible endpoint. The in-process sources stay local and keyless like the default; a configured endpoint is an out-of-process call that can be remote and require an API key. The four sources, their options, and how to switch models live on [Embedding models](https://www.ratel.sh/docs/embedding-models). ## Keyword vs semantic vs hybrid * **Stay on BM25** (the default) until you see missed recall. When it misses, the first fix is usually [better definitions](#keyword-search), not a different engine. * **Pick semantic** when your queries genuinely paraphrase: query and tool share no vocabulary, and rewording the definitions cannot close the gap. * **Pick hybrid** to keep BM25's exact-identifier precision and add semantic's paraphrase recall. If you opt in at all, hybrid is usually the safer choice. * Semantic and hybrid pay the model's costs: a one-time download, resident memory, embedding work at register time, and possible load failures. BM25 pays none of that. ## Next steps [Progressive disclosure tools](https://www.ratel.sh/docs/progressive-disclosure-tools) The contract the three capability tools expose to the model, and how their buckets are ranked. [Register tools (TypeScript)](https://www.ratel.sh/docs/typescript/register-tools) Fill the catalog the ranking searches: local functions and upstream MCP servers. [Register tools (Python)](https://www.ratel.sh/docs/python/register-tools) The same registration flow, idiomatic Python. [Optimize agent capabilities](https://www.ratel.sh/docs/optimization) Why a searchable catalog cuts tokens per turn, with the benchmark numbers. --- # Embedding models Source: https://www.ratel.sh/docs/embedding-models [Semantic and hybrid retrieval](https://www.ratel.sh/docs/retrieval-and-search#semantic-and-hybrid-search) embed with **`BAAI/bge-small-en-v1.5`** by default: in-process, keyless, English-only. The model is configurable per catalog ([ADR 0012](https://github.com/ratel-ai/ratel/blob/main/docs/adr/0012-configurable-embedding-models.md)). Declare it once, at construction, with the `embedding` option next to `method` — `ratel({ method, embedding })` in TypeScript, the catalog constructor in Python. The configured embedding spec defines the vector space used for both corpus and query embeddings. For semantic and hybrid catalogs without an artifact, the corpus is embedded during `register`. An experimental [build-time artifact](#build-time-embedding-artifacts-experimental) can preload covered corpus vectors instead. Construction only validates the spec. Model resolution, loading, endpoint calls, and artifact compatibility checks happen later during dense preparation or search. ## The four sources * **HuggingFace repo**: `{ huggingface: "BAAI/bge-base-en-v1.5" }`. Runs in-process via Candle, like the default. BERT-family sentence-transformers models (bge, e5, gte, MiniLM). * **Local directory**: a bare path string, or `{ local: "/opt/models/bge" }`. The same in-process engine for an air-gapped or private checkpoint. * **Ollama**: `{ ollama: "nomic-embed-text" }`. Calls the local Ollama server at `http://localhost:11434/v1/embeddings`; a 404 hints `ollama pull `. * **OpenAI-compatible endpoint**: `{ url, model, apiKeyEnv }`. Any `/embeddings` API: OpenAI, TEI, vLLM, a remote Ollama, or [Ratel Cloud's hosted embeddings](https://www.ratel.sh/docs/cloud/embeddings). This is the route for non-BERT and multilingual models the in-process engine cannot load, and the only embedding source that can send input text to a remote inference service or need an API key. The source is always named by its key, never guessed: a bare string that looks like a repo id or a URL is rejected with a pointer to the right object form. **Python** ```python from ratel_ai import ToolCatalog # A bigger HuggingFace model, still in-process and keyless. catalog = ToolCatalog( method="hybrid", embedding={"huggingface": "BAAI/bge-base-en-v1.5", "download": True}, ) # An OpenAI-compatible endpoint; the key is read from the env at call time. remote = ToolCatalog( method="semantic", embedding={ "url": "https://api.openai.com/v1/embeddings", "model": "text-embedding-3-small", "api_key_env": "OPENAI_API_KEY", }, ) ``` **TypeScript** ```ts import { ratel } from "@ratel-ai/sdk"; // A bigger HuggingFace model, still in-process and keyless. const r = ratel({ method: "hybrid", embedding: { huggingface: "BAAI/bge-base-en-v1.5", download: true }, }); // An OpenAI-compatible endpoint; the key is read from the env at call time. const remote = ratel({ method: "semantic", embedding: { url: "https://api.openai.com/v1/embeddings", model: "text-embedding-3-small", apiKeyEnv: "OPENAI_API_KEY", }, }); ``` In TypeScript, `ratel({ embedding })` forwards the spec to both internal catalogs; the standalone `ToolCatalog` and `SkillCatalog` take the same option, and so do the registries underneath. The tool and skill caches are independent, so two catalogs can run different models. ## In-process models * **Cache-only by default.** Ratel auto-downloads only the built-in default; a configured HuggingFace model must already be in the HuggingFace cache (`huggingface-cli download `), or you opt in with `download: true`. A missing model errors with that hint (`EmbedderError`, code `NotCached`); `register` never silently pulls gigabytes. When a cold download does happen it emits an `embedder_download` [trace event](https://www.ratel.sh/docs/observability) with the actual byte size. * **`revision`** pins a git revision (default `main`) for reproducible vectors. * **Pooling is auto-detected** from the repo's `1_Pooling/config.json`; `pooling: "cls" | "mean"` overrides it. A model with no pooling metadata warns and assumes mean. * **Asymmetric models** (e5 and friends) take `queryPrefix` / `docPrefix` (`query_prefix` / `doc_prefix` in Python); each side is prefixed automatically. * A non-BERT model fails to load with a typed error that points at the endpoint route. ## Endpoints * **`apiKeyEnv` / `api_key_env` names the env var** holding the bearer key, read at call time; the secret never lands in code or serialized config. A named-but-unset var is an immediate error, not a downstream 401. Omit it for keyless servers. * Document batches are chunked (up to 64 inputs per request), keep their order, and commit only after every chunk succeeds. * Every vector from any source is L2-normalized at the boundary, so an endpoint returning un-normalized vectors cannot skew cosine ranking. * For semantic and hybrid catalogs, the dense cache is prepared during registration. Without an embedding artifact, each fresh process embeds the registered corpus through the configured backend. Stable catalogs can instead configure an experimental build-time embedding artifact to reuse covered corpus/document vectors. Over a remote endpoint, corpus embedding adds real latency and cost when those vectors are not reused, so pass whole batches to `register` and prefer a long-lived process. ## Build-time embedding artifacts (experimental) If the searchable metadata for your Tools or Skills is known at build or deploy time, its embeddings can be built once and stored in an artifact. The artifact stores precomputed vectors for matching catalog entries; it does not replace Tool or Skill registration, executors, or the runtime catalog itself. At runtime, register the same catalog entries as usual and configure the artifact alongside them. Ratel warms covered entries from those vectors on `register` (and skills `replaceAll` / `replace_all`) instead of running corpus/document embedding inference again. Artifacts only replace corpus/document inference for covered entries. They do not eliminate query embeddings or all runtime embedding-backend work: Ratel still validates artifact compatibility, Local/HuggingFace models may still initialize or load, and an endpoint-backed configuration may make a compatibility request when needed. Semantic and hybrid searches continue to embed each query with the configured backend. **Python** ```python from ratel_ai import ( ExecutableTool, Skill, SkillCatalog, ToolCatalog, experimental_build_embedding_artifact, ) embedding = {"huggingface": "BAAI/bge-base-en-v1.5", "download": True} tool = ExecutableTool( id="read_file", name="Read file", description="Read a text file.", execute=lambda _args: {"contents": "..."}, ) skill = Skill( id="file-review", name="File review", description="Review and summarize a text file.", ) await experimental_build_embedding_artifact( output="./catalog.ratel-embeddings", embedding=embedding, tools=[tool], skills=[skill], ) artifact = {"path": "./catalog.ratel-embeddings"} tools = ToolCatalog( method="hybrid", embedding=embedding, experimental_embedding_artifact=artifact, ) skills = SkillCatalog( method="hybrid", embedding=embedding, experimental_embedding_artifact=artifact, ) await tools.register(tool) await skills.register(skill) ``` **TypeScript** ```ts import { experimentalBuildEmbeddingArtifact, ratel, type ExecutableTool, type Skill, } from "@ratel-ai/sdk"; const embedding = { huggingface: "BAAI/bge-base-en-v1.5", download: true }; const tool: ExecutableTool = { id: "read_file", name: "Read file", description: "Read a text file.", inputSchema: {}, outputSchema: {}, execute: async () => ({ contents: "..." }), }; const skill: Skill = { id: "file-review", name: "File review", description: "Review and summarize a text file.", }; await experimentalBuildEmbeddingArtifact({ output: "./catalog.ratel-embeddings", embedding, tools: [tool], skills: [skill], }); const r = ratel({ method: "hybrid", embedding, experimentalEmbeddingArtifact: { path: "./catalog.ratel-embeddings", }, }); await r.tools.register(tool); await r.skills.register(skill); ``` * Configure `path` or `bytes` at runtime (exactly one). * Default miss policy is `"error"` (`onMiss` / `on_miss`). * `"embed"` reuses covered entries and embeds only missing or invalidated ones. * Runtime warm never updates or writes through to the artifact. * One mixed artifact can contain both Tool and Skill entries. In TypeScript, `ratel()` forwards the configured artifact to both catalogs; in Python, the same artifact can be configured on both `ToolCatalog` and `SkillCatalog`. * With default `"error"`, every non-empty Tool or Skill corpus registered with that artifact must be fully covered. * Artifact/model incompatibility fails closed; rebuild the artifact after changing the embedding model. ## Changing the model To use a different embedding model, construct a new catalog — or a new `ratel()` core in TypeScript — with the new embedding spec and register the corpus again. If you use a build-time embedding artifact, build a new artifact with that same embedding spec before using it with the new catalog. Ratel validates the model identity and vector dimensions associated with dense embeddings and artifacts. Incompatible vector spaces fail instead of being mixed into the same search index. Embedding-backend failures such as model loading, download, endpoint, authentication, and dimension errors surface as `EmbedderError` (with `DimensionMismatchError` for dimension mismatches). They can surface during awaited dense preparation or while a semantic/hybrid search embeds its query. Failures while warming a configured embedding artifact surface as `ArtifactWarmError` from awaited registration or replacement operations such as `register(...)`, `replaceAll(...)`, and `replace_all(...)`. For embedding configuration types, see [`EmbeddingSpec` and `EmbeddingModelConfig` (TypeScript)](https://www.ratel.sh/docs/packages/typescript/sdk#api-embeddingspec) and [the config dicts (Python)](https://www.ratel.sh/docs/packages/python/ratel-ai). ## Next steps [Retrieval and search](https://www.ratel.sh/docs/retrieval-and-search#semantic-and-hybrid-search) When semantic and hybrid are worth it, and how the engines rank and fail. [Semantic & hybrid search (TypeScript)](https://www.ratel.sh/docs/typescript/semantic-search) The end-to-end dense workflow: prepare the dense cache at register, searchAsync, typed errors. [Semantic & hybrid search (Python)](https://www.ratel.sh/docs/python/semantic-search) The same workflow, idiomatic Python. --- # Benchmark Source: https://www.ratel.sh/docs/benchmark Ratel's numbers come from an open harness, [`ratel-ai/ratel-bench`](https://github.com/ratel-ai/ratel-bench), and the published results live at [ratel.sh/benchmark](https://www.ratel.sh/benchmark). ## Three arms Every scenario runs three ways on the same model: * **control-baseline**: the entire candidate tool pool is exposed to the agent on every turn. * **control-oracle**: only the gold tool is exposed; the accuracy upper bound. * **ratel-full**: the model sees a handful of targeted tools plus the capability tools, regardless of how large the underlying pool is. The gap between baseline and ratel-full is the token saving; the gap to oracle is the accuracy headroom. ## Two suites * **BFCL**: the [Berkeley Function-Calling Leaderboard](https://gorilla.cs.berkeley.edu/leaderboard.html), simple and multiple splits, with pool sizes swept across realistic MCP setups (30 → 600 tools). * **Skills suite**: agent tasks over six datasets (bigcodebench, champ, logicbench, medcalcbench, theoremqa, toolqa), measuring retrieval and completion on multi-step work. ## What gets measured Per model, arm, and split: task completion, tool selection accuracy, recall, mean total tokens, and latency. A separate retriever evaluation sweeps the pool sizes (precision, recall, MRR, nDCG). ## Where results live [ratel.sh/benchmark](https://www.ratel.sh/benchmark) renders the reports as interactive charts and tables, methodology included. The site regenerates at build time from ratel-bench's published reports, so it always tracks the source of truth. For the headline numbers and what they mean in your agent, see [Efficiency](https://www.ratel.sh/docs/optimization#efficiency). --- # Licensing Source: https://www.ratel.sh/docs/licensing Ratel uses two permissive licenses: the `ratel-ai-core` Rust crate (the engine) is **Apache-2.0**, and everything else is **MIT**. The split is decided in [ADR 0009](https://github.com/ratel-ai/ratel/blob/main/docs/adr/0009-licensing.md). | Component | License | Why | | -------------------------------------------------------------------------------------------------------- | ---------- | ---------------------------------------------- | | `ratel-ai-core` (the engine) | Apache-2.0 | Explicit patent grant on the code others embed | | SDKs (`@ratel-ai/sdk`, `ratel-ai`), native bindings, telemetry packages, examples | MIT | Matches ecosystem norms | | Ratel Local (`@ratel-ai/ratel-local`, [`ratel-ai/ratel-local`](https://github.com/ratel-ai/ratel-local)) | MIT | Same | | Skills ([`ratel-ai/skills`](https://github.com/ratel-ai/skills)) | MIT | Same | ## What this means for you * **Embed and redistribute freely.** Both licenses permit commercial use, modification, and redistribution. * **Patent protection.** The engine's Apache-2.0 license includes an explicit patent grant. * **Mixing is standard.** MIT code depending on Apache-2.0 code is a common, one-way-compatible combination: no extra obligations for consumers. * **Old versions keep their terms.** Versions published before the relicense stay under the license they shipped with. There is no AGPL, SSPL, BSL, or any other source-available or copyleft license anywhere in the repo, a decision recorded in ADR 0009. ## License texts * [LICENSE-APACHE](https://github.com/ratel-ai/ratel/blob/main/LICENSE-APACHE): Apache-2.0, for the engine * [LICENSE.md](https://github.com/ratel-ai/ratel/blob/main/LICENSE.md): MIT, for everything else * [NOTICE](https://github.com/ratel-ai/ratel/blob/main/NOTICE): per-component breakdown --- # Skillset Source: https://www.ratel.sh/docs/skillset The Ratel skills suite is five open-source skills for coding agents (Claude Code, Cursor, Codex, and 40+ others) that audit your agent codebase, plan its observability, integrate Ratel, and fix what the audit finds. The [Quick start](https://www.ratel.sh/docs/quickstart) has the one-liner and the two prompts that kick it off; this page is the reference for what each skill does and how they fit together. > This page covers the **skills suite**, Ratel's installable skills for coding agents; for the skills *concept*, reusable playbooks registered on the SDK's skill catalog (`r.skills` from `ratel()` in TypeScript, `SkillCatalog` in Python), see [Tools, MCP & Skills](https://www.ratel.sh/docs/tools-mcp-skills). ## Install the skills **npm** ```bash npx skills add ratel-ai/skills -y --all ``` **pnpm** ```bash pnpm dlx skills add ratel-ai/skills -y --all ``` **yarn** ```bash yarn dlx skills add ratel-ai/skills -y --all ``` **bun** ```bash bunx skills add ratel-ai/skills -y --all ``` That installs all five skills into the current project (`./.claude/skills/` for Claude Code, the equivalent location for other supported agents). The `-y` flag accepts all prompts; `--all` installs all skills into all agents; add `-g` to install globally (`~/.claude/skills/`). Per-skill install: **npm** ```bash npx skills add ratel-ai/skills --skill ratel-assessment ``` **pnpm** ```bash pnpm dlx skills add ratel-ai/skills --skill ratel-assessment ``` **yarn** ```bash yarn dlx skills add ratel-ai/skills --skill ratel-assessment ``` **bun** ```bash bunx skills add ratel-ai/skills --skill ratel-assessment ``` The `skills` CLI is [Vercel Labs' open agent skills tool](https://github.com/vercel-labs/skills), compatible with Claude Code, Cursor, Codex, OpenCode, Gemini CLI, and 40+ other coding agents. The suite itself is [MIT-licensed](https://github.com/ratel-ai/skills/blob/main/LICENSE) at [`ratel-ai/skills`](https://github.com/ratel-ai/skills). ## What's inside Five skills. The first three run the engagement arc in order; the last two are the fix-skills the assessment routes to when it finds a long system prompt or weak tool/skill definitions. | Skill | What it does | When to fire | | ------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | | [`ratel-assessment`](https://github.com/ratel-ai/skills/blob/main/skills/ratel-assessment/SKILL.md) | Static read of the agent codebase (TypeScript or Python). Writes a 12-dimension scorecard with evidence-backed findings to `/.ratel/`: markdown plus a scored HTML version. | First touch. Zero setup. | | [`ratel-observability-assessment`](https://github.com/ratel-ai/skills/blob/main/skills/ratel-observability-assessment/SKILL.md) | Detects the OpenTelemetry backend you run, plans turning on Ratel's native OTLP telemetry, and proposes the dashboards that prove value. | After assessment flags Observability as Weak / Missing. | | [`ratel-integrate`](https://github.com/ratel-ai/skills/blob/main/skills/ratel-integrate/SKILL.md) | Plans the rollout: integration mode (direct SDK / Ratel Local / hybrid), pilot scope, and an A/B test tied to Ratel's native-telemetry metrics. Covers both SDKs. | After observability is in. | | [`ratel-decompose-prompt`](https://github.com/ratel-ai/skills/blob/main/skills/ratel-decompose-prompt/SKILL.md) | Breaks a monolithic system prompt into a lean core plus extractable skills registered in Ratel's skill catalog. | When assessment flags Prompt decomposition as Weak / Missing. | | [`ratel-tune-definitions`](https://github.com/ratel-ai/skills/blob/main/skills/ratel-tune-definitions/SKILL.md) | Rewrites tool and skill definitions (descriptions, names, schemas, tags) for retrievability and model usability. Writes a before/after plan. | When assessment flags Definition quality as Weak / Missing. | ## How the skills chain ```text ratel-assessment → "here's what's weak; here's where Ratel fits" ↓ (Observability Weak/Missing) ratel-observability-assessment → "turn on Ratel's native OTLP telemetry" ↓ ratel-integrate → "here's how to roll Ratel out + A/B it" ratel-assessment also branches to two fix-skills, conditional on findings: ├─ Prompt decomposition weak → ratel-decompose-prompt └─ Definition quality weak → ratel-tune-definitions ``` The arc isn't a forced sequence: each report names which sibling to run next based on what it found. All reports land under `.ratel/` in your repo, so they accumulate and stay diff-able. ## Observability Ratel telemetry **is** OpenTelemetry: the SDKs emit the retrieval-and-tool funnel natively as `gen_ai.*` spans plus a `ratel.*` overlay, exported as stock OTLP. Point those spans at the backend you already run: Langfuse, LangSmith, your own collector, or [Ratel Cloud](https://www.ratel.sh/docs/cloud), coming soon. `ratel-observability-assessment` plans that setup (greenfield or dual-exported into an existing provider) and the dashboards that prove value. --- # SDKs Source: https://www.ratel.sh/docs/sdks Ratel ships two first-party SDKs that bundle the same Rust core ([`ratel-ai-core`](https://github.com/ratel-ai/ratel)), **[`@ratel-ai/sdk`](https://www.ratel.sh/docs/typescript/quickstart)** for TypeScript / Node and **[`ratel-ai`](https://www.ratel.sh/docs/python/quickstart)** for Python. The API is mirrored across both; the [`ratel()` factory](https://www.ratel.sh/docs/typescript/quickstart) and [framework adapters](https://www.ratel.sh/docs/typescript/framework-integrations) are TypeScript-only today. Everything runs **in-process**: no external vector DB or embedding service, no API key, nothing to deploy. BM25 needs no embeddings; semantic and hybrid retrieval opt into a local embedding model, downloaded once on first use and cached. The wedge today is **tool selection**. Register your tools (or ingest an upstream MCP server) once — `ratel()` in TypeScript, a `ToolCatalog` in Python; a BM25 index ranks them so the model sees the handful that matter for the current turn, reached through two self-service tools, `search_capabilities` and `invoke_tool`, instead of the full list. Use the toggle to switch languages; your choice follows you across every page. ## Install `npm install @ratel-ai/sdk` or `pip install ratel-ai`. Versions, extras, native targets, and Ratel Local live on the [Install](https://www.ratel.sh/docs/install) page. ## Register a catalog, hand the agent two tools The catalog pairs each tool's metadata with an executable handler. In TypeScript, `ratel()` creates the core and `r.modelTools()` hands back the capability tools; in Python you build them from a `ToolCatalog` with `search_capabilities_tool` / `invoke_tool_tool`. They let your agent search the catalog and invoke anything in it. Wire them into any framework. **Python** ```python import asyncio from ratel_ai import ToolCatalog, ExecutableTool, search_capabilities_tool, invoke_tool_tool catalog = ToolCatalog() asyncio.run(catalog.register( ExecutableTool( id="read_file", name="read_file", description="Read a file from local disk.", input_schema={"properties": {"path": {"type": "string"}}}, output_schema={"properties": {"contents": {"type": "string"}}}, execute=lambda args: {"contents": open(args["path"]).read()}, ) )) # The full catalog stays out of context; the agent reaches it via these two. search = search_capabilities_tool(catalog) # id == "search_capabilities" invoke = invoke_tool_tool(catalog) # id == "invoke_tool" ``` **TypeScript** ```typescript 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.", inputSchema: { properties: { path: { type: "string" } } }, outputSchema: { properties: { contents: { type: "string" } } }, execute: async ({ path }) => ({ contents: await readFile(path, "utf8") }), }); // The full catalog stays out of context; the agent reaches it via these two. // (modelTools() also returns get_skill_content, for skills.) const { search_capabilities, invoke_tool } = r.modelTools(); ``` Need only ranking, no execution? Use `ToolRegistry` (metadata-only BM25 index) and dispatch calls yourself. Both layers are covered in the language references below. ## Full reference The per-language pages document the full shipped API of [`ratel-ai/ratel`](https://github.com/ratel-ai/ratel): every class, capability tool, and telemetry hook, with mirrored snippets in both languages. [TypeScript SDK](https://www.ratel.sh/docs/typescript/quickstart) `@ratel-ai/sdk`: the `ratel()` core, the ToolCatalog/ToolRegistry layer under it, capability tools, MCP ingestion, telemetry. [Python SDK](https://www.ratel.sh/docs/python/quickstart) `ratel-ai`: the same catalog and capability-tool API, idiomatic Python. ## End-to-end examples Runnable agents that wire a catalog into a real framework — via an official adapter or a hand-rolled capability-tool loop: * [`examples/ai-sdk`](https://github.com/ratel-ai/ratel/tree/main/examples/ai-sdk), Vercel AI SDK via `@ratel-ai/vercel-ai-sdk` (`ToolLoopAgent` + `prepareStep`) * [`examples/mastra`](https://github.com/ratel-ai/ratel/tree/main/examples/mastra), Mastra via `@ratel-ai/mastra` (`recallProcessor()` input processor) * [`examples/pydantic-ai`](https://github.com/ratel-ai/ratel/tree/main/examples/pydantic-ai), Pydantic AI * [`examples/mcp-chat`](https://github.com/ratel-ai/ratel/tree/main/examples/mcp-chat), REPL ingesting an upstream MCP server via `registerMcpServer` --- # Related projects Source: https://www.ratel.sh/docs/related-projects The Ratel SDK is one piece of a small open ecosystem. Everything lives under [github.com/ratel-ai](https://github.com/ratel-ai). ## ratel The core repo: [`ratel-ai/ratel`](https://github.com/ratel-ai/ratel). One Rust core, two first-party SDKs: [`@ratel-ai/sdk`](https://www.ratel.sh/docs/typescript/quickstart) for TypeScript and [`ratel-ai`](https://www.ratel.sh/docs/python/quickstart) for Python. Start at the [SDK overview](https://www.ratel.sh/docs/sdks). ## Ratel Local One MCP server that fronts your whole tool estate. Claude Code, Codex, and Cursor get `search_capabilities`, `invoke_tool`, and `get_skill_content` without a line of SDK code. Ships as [`@ratel-ai/ratel-local`](https://www.npmjs.com/package/@ratel-ai/ratel-local) on npm; the CLI is `ratel-local`. Start with the [Claude Code and Codex quickstart](https://www.ratel.sh/docs/local/quickstart) or [manual Cursor setup](https://www.ratel.sh/docs/local/cursor), then use [Troubleshooting](https://www.ratel.sh/docs/local/troubleshooting) when something breaks. ## ratel-bench The benchmark harness behind Ratel's numbers: [`ratel-ai/ratel-bench`](https://github.com/ratel-ai/ratel-bench). Published results live at [ratel.sh/benchmark](https://www.ratel.sh/benchmark). ## Skills suite Five installable audit and engagement skills for coding agents: assess an agent codebase, plan its observability, integrate Ratel, and fix what the audit finds. Repo: [`ratel-ai/skills`](https://github.com/ratel-ai/skills). Install and usage: [Skillset](https://www.ratel.sh/docs/skillset). ## Ratel Cloud Hosted context analytics: inspect context usage and retrieval performance across your agents. Coming soon. See [Ratel Cloud](https://www.ratel.sh/docs/cloud). --- # Install Source: https://www.ratel.sh/docs/typescript/install One package, no service: ```bash npm install @ratel-ai/sdk ``` The package is ESM (`type: module`) and needs **Node.js 20.6 or newer**. ## Prebuilt native targets The SDK bundles Ratel's Rust core as a native module. Prebuilt bindings ship for: * macOS: `darwin-arm64`, `darwin-x64` * Linux (glibc): `linux-x64-gnu`, `linux-arm64-gnu` * Windows: `win32-x64-msvc` On these targets nothing compiles at install time and no Rust toolchain is needed. ## What else you might install * **MCP ingestion**: [`registerMcpServer`](https://www.ratel.sh/docs/typescript/register-mcps) works out of the box; the MCP client (`@modelcontextprotocol/sdk`) ships as a dependency. * **Semantic & hybrid retrieval**: nothing to install. Opting in downloads the default local [embedding model](https://www.ratel.sh/docs/embedding-models) on first use; see [The embedding cache](https://www.ratel.sh/docs/typescript/register-tools#the-embedding-cache) and [Semantic & hybrid search](https://www.ratel.sh/docs/retrieval-and-search#semantic-and-hybrid-search). ## Next steps [Quickstart](https://www.ratel.sh/docs/typescript/quickstart) Register a tool and let an agent discover it through the capability tools. [Packages](https://www.ratel.sh/docs/typescript/packages) The four npm packages behind the TypeScript surface. --- # Quickstart Source: https://www.ratel.sh/docs/typescript/quickstart This is the TypeScript path. Python reader? Start at the [Python quickstart](https://www.ratel.sh/docs/python/quickstart). Rather not write the integration yourself? Point your coding agent at the [skills suite](https://www.ratel.sh/docs/skillset). Ratel is framework-agnostic: everything the SDK hands you is a plain executable tool (metadata plus an `execute` handler) that plugs into any agent loop. This page shows the two ways in: [starting from scratch](#start-from-scratch) and [migrating an existing agent](#migrate-an-existing-agent). > **Before you start** > > Use Node.js 20 or newer. Nothing on this page calls a model, so no API key is needed. ## How it works Everything starts with **`ratel()`**: one core holding a tool catalog and a skill catalog. Register each of your tools once on `r.tools`, pairing its metadata (id, description, JSON schemas) with the handler that runs it; register Markdown playbooks on `r.skills`, ranked in their own corpus. The agent reaches both through the three capability tools from `r.modelTools()`: `search_capabilities` (find tools and skills by description), `invoke_tool` (run a tool by id), and `get_skill_content` (load a skill's body), so the full catalog never enters the prompt. How the pieces fit per turn is covered in [Architecture](https://www.ratel.sh/docs/architecture). ## Start from scratch ### Create a project and install the SDK ```bash mkdir ratel-quickstart && cd ratel-quickstart npm init -y npm pkg set type=module npm install @ratel-ai/sdk npm install --save-dev tsx ``` Requirements, prebuilt targets, and optional packages are on [Install](https://www.ratel.sh/docs/typescript/install). ### Register a tool and a skill Create `index.ts`. Tools carry their metadata and handler; skills carry a description and a Markdown body: ```typescript import { ratel } from "@ratel-ai/sdk"; const r = ratel(); await r.tools.register({ id: "get_weather", name: "get_weather", description: "Get the current weather for a city.", inputSchema: { type: "object", properties: { city: { type: "string", description: "City name" }, }, required: ["city"], }, outputSchema: { type: "object", properties: { city: { type: "string" }, temperatureC: { type: "number" }, condition: { type: "string" }, }, required: ["city", "temperatureC", "condition"], }, execute: async ({ city }) => ({ city, temperatureC: 24, condition: "sunny", }), }); await r.skills.register({ id: "weather-briefing", name: "weather-briefing", description: "How to report the weather: temperature first, then condition.", body: "## Weather briefing\nAnswer in one sentence: temperature first, then the condition. No filler.", }); // The three capability tools are the only tools your agent needs to see. // Always all three — the set never depends on registration order. const { search_capabilities, invoke_tool, get_skill_content } = r.modelTools(); ``` ### Run the discovery loop by hand Append the exact calls a model makes at runtime, then run the file: ```typescript console.log(await search_capabilities.execute({ query: "weather in Rome" })); console.log(await invoke_tool.execute({ toolId: "get_weather", args: { city: "Rome" } })); console.log(await get_skill_content.execute({ skillId: "weather-briefing" })); ``` ```bash npx tsx index.ts ``` Three results, no model key involved: * `search_capabilities` returns two independently-ranked buckets: `get_weather` under `tools`, `weather-briefing` under `skills`. Neither schema nor body entered any prompt to get there. * `invoke_tool` runs the handler and returns its result untouched: `{ city: "Rome", temperatureC: 24, condition: "sunny" }`. * `get_skill_content` returns the playbook: `{ body: "## Weather briefing\n…" }`. The full result shapes and error contract are on [Progressive disclosure tools](https://www.ratel.sh/docs/progressive-disclosure-tools). ### Hand the loop to your agent Using the Vercel AI SDK or Mastra? `r.adaptTo(...)` gives the same core a framework-shaped view through an official adapter — see [Framework integrations](https://www.ratel.sh/docs/typescript/framework-integrations). Anywhere else, each capability tool is a plain `ExecutableTool`: `{ id, name, description, inputSchema, outputSchema, execute }`. Map those fields onto your framework's tool type (a few lines), add the three tools to the agent's tool list, and tell it to search before answering: ```text Find tools and playbooks with search_capabilities. Run tools with invoke_tool. When a skill matches, load it with get_skill_content and follow it. ``` Host-driven ranking is there too: `await r.recall(query)` returns the canonical `search_capabilities` result (or `null` on no match) without going through the model. More on [Use the discovery tools](https://www.ratel.sh/docs/typescript/discovery-tools). ## Migrate an existing agent A migration changes where capabilities live, not your model or business logic. Start with one workflow, prove it still behaves the same, then repeat. | Existing asset | After migration | | --------------------------------------------------------------------- | ------------------------------------------------------------------------------- | | Tool IDs, schemas, and handlers | Register once on `r.tools`; keep authorization and side effects in the handler. | | Conditional runbooks and examples | Register on `r.skills`; load only when relevant. | | Identity, safety, universal policy, output rules, and runtime context | Keep in the core prompt. | | Model and agent loop | Keep both; reuse the loop's prompt and tool seams. | ### Find the integration points Find where your process creates long-lived dependencies and where each request builds the model's tool list. Create the `ratel()` core at startup; change the model-facing list at the request boundary. ### Register an existing tool Tool definitions leave the repeated model request, not your code. Preserve the ID, schema contract, handler, authorization, permissions, and side effects. Convert framework-native schemas to JSON Schema at this boundary. ```typescript // Before: the schema is sent to the model on every request const toolsForEveryRequest = { issue_refund: existingRefundTool }; // After: the same contract is registered once at startup const r = ratel(); await r.tools.register({ id: "issue_refund", name: "issue_refund", description: "Issue an approved refund for an order.", inputSchema: issueRefundInputSchema, outputSchema: issueRefundOutputSchema, execute: issueRefund, // the existing handler }); ``` Register each tool in this workflow the same way — `r.tools.register` is variadic, so a batch is one call. During a gradual migration, leave unmigrated tools in the old list and remove each migrated definition from it. ### Extract an on-demand skill Keep instructions and runtime context that must govern every turn in the core prompt. Move only conditional guidance, such as the refund workflow, into a self-contained skill: ```typescript // Before: the refund workflow is sent on every turn const instructions = [ CORE_PROMPT, SAFETY_POLICY, OUTPUT_RULES, RUNTIME_CONTEXT, REFUND_RUNBOOK, ].join("\n\n"); // After: everything except the runbook stays in the core prompt const coreInstructions = [ CORE_PROMPT, SAFETY_POLICY, OUTPUT_RULES, RUNTIME_CONTEXT, ].join("\n\n"); await r.skills.register({ id: "refund-runbook", name: "refund-runbook", description: "Check refund eligibility, obtain approval, then issue the refund.", tools: ["issue_refund"], body: REFUND_RUNBOOK, }); ``` The description says when to load the skill. Its `tools` list surfaces `issue_refund` beside the matched playbook. ### Rewire and verify the agent Add the discovery policy to the core prompt, then hand the agent the capability tools at the request boundary: ```typescript const discoveryInstructions = ` Find tools and playbooks with search_capabilities. Run tools with invoke_tool. When a skill matches, load it with get_skill_content and follow it. `.trim(); const instructions = [coreInstructions, discoveryInstructions].join("\n\n"); const capabilityTools = r.modelTools(); ``` The capability tools are plain `ExecutableTool` values; [Framework integrations](https://www.ratel.sh/docs/typescript/framework-integrations) has the official adapters and the hand-rolled recipe. A refund request should now follow this path: 1. `search_capabilities` returns `refund-runbook` and `issue_refund`. 2. `get_skill_content` loads the eligibility and approval steps. 3. `invoke_tool` calls the unchanged `issueRefund` handler. 4. The agent answers from the tool result. Before migrating the next workflow, verify the skill ranks, its declared tool appears, the handler receives the same arguments, and the initial request excludes the full catalog and refund runbook. > **Let a coding agent do the migration** > > The [skills suite](https://www.ratel.sh/docs/skillset) ships skills for Claude Code, Cursor, Codex, and > similar: `ratel-integrate` plans this migration for your codebase, and > `ratel-decompose-prompt` splits the system prompt into skills. ## Next steps [Framework integrations](https://www.ratel.sh/docs/typescript/framework-integrations) Official adapters for the Vercel AI SDK and Mastra, and the recipe for any other loop. [Skillset](https://www.ratel.sh/docs/skillset) Coding-agent skills that assess your codebase and apply this integration for you. [Register tools](https://www.ratel.sh/docs/typescript/register-tools) The anatomy of an executable tool, the full r.tools surface, and ranking without execution. [Register skills](https://www.ratel.sh/docs/typescript/register-skills) The full skill-catalog surface: tags, declared tools, and how skills rank. --- # Register tools Source: https://www.ratel.sh/docs/typescript/register-tools Everything the model can reach lives in one shared **tool catalog** inside the [`ratel()`](https://www.ratel.sh/docs/quickstart) 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](https://www.ratel.sh/docs/typescript/register-mcps). ## 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: ```ts 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)`, the `toolId` the model passes to `invoke_tool`, and the `toolId` on every search hit. Unique per catalog; [MCP-ingested tools](https://www.ratel.sh/docs/typescript/register-mcps) 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](https://www.ratel.sh/docs/retrieval-and-search#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. `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 `r.tools` surface `register` is variadic: pass the whole batch of native `ExecutableTool`s in one call. The rest of the handle: ```ts 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; 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 underneath ``` Three behaviors worth knowing: * **`register`** validates synchronously: a missing `execute`, a reserved id, or a framework-shaped tool (register those on an [adapter view](https://www.ratel.sh/docs/typescript/framework-integrations)) 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`, and `get_skill_content` name the capability tools themselves and cannot be registered. * **`search`** and **`searchAsync`** take an optional trailing `method` and clamp `topK` to \[1, 50] (an invalid value falls back to 5). Synchronous `search` is BM25-only: a `"semantic"`/`"hybrid"` method throws with guidance to call `searchAsync`, 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`. ```ts 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; 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 only ``` Assembled 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](https://www.ratel.sh/docs/typescript/telemetry) (default: none), `method` sets the default retrieval method (default `"bm25"`; see [Use the discovery tools](https://www.ratel.sh/docs/typescript/discovery-tools)), and `embedding` selects the [embedding model](https://www.ratel.sh/docs/embedding-models) for semantic/hybrid (default `bge-small-en-v1.5`). Some helpers take the raw catalog: [`registerMcpServer(r.tools.catalog, …)`](https://www.ratel.sh/docs/typescript/register-mcps). Two behaviors worth knowing: * **`invoke`** throws `unknown toolId: ` for an id that was never registered. It awaits sync and async executors alike (`Executor = (input: any, context?: unknown) => Promise | unknown`; `context` is an opaque invocation context forwarded unchanged through `invoke_tool`) 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?)`; `searchAsync` mirrors it and returns a `Promise`. Pass `"agent"` to tag a search as model-initiated in telemetry. Synchronous `search` is BM25-only; a `"semantic"`/`"hybrid"` `method` throws with guidance to call `searchAsync`, which runs any method off the event loop. Unlike `r.tools.search` and 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: ```ts // 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](https://www.ratel.sh/docs/typescript/semantic-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](https://www.ratel.sh/docs/embedding-models). The default model's footprint, offline use, and when the engines are worth it are on [Semantic & hybrid search](https://www.ratel.sh/docs/retrieval-and-search#semantic-and-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. ```ts 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: ```ts 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 only ``` There 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. ```ts 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 [Register MCP servers](https://www.ratel.sh/docs/typescript/register-mcps) Ingest an upstream server's tools into the same catalog with registerMcpServer. [Use the discovery tools](https://www.ratel.sh/docs/typescript/discovery-tools) Hand the model search\_capabilities and invoke\_tool, plus a top-K pre-filter. [Keyword search](https://www.ratel.sh/docs/retrieval-and-search#keyword-search) How BM25 ranks the catalog, and how to write descriptions and schemas it finds. --- # Register MCP servers Source: https://www.ratel.sh/docs/typescript/register-mcps Hand `registerMcpServer` the core's catalog and a connected MCP transport: Ratel calls `tools/list`, registers each upstream tool under a server-namespaced id (`__`), and wires its executor to `tools/call` over the same connection. The upstream tools rank and run alongside your local ones. ## Ingest an MCP server's tools The SDK owns the MCP client: hand it a transport and it connects, reads the server's instructions, and lists the tools itself. For a remote server, that transport is Streamable HTTP: ```ts import { ratel, registerMcpServer } from "@ratel-ai/sdk"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const r = ratel(); const handle = await registerMcpServer(r.tools.catalog, { name: "issues", transport: new StreamableHTTPClientTransport( new URL("https://example.com/mcp"), ), }); // handle.toolIds → ["issues__create_issue", "issues__search_issues", ...] // handle.serverInstructions → the upstream's instructions, if any // r.tools.search / r.tools.invoke now rank and run the upstream tools alongside local ones. await handle.close(); // disconnect on shutdown ``` `registerMcpServer` is driver-level: it takes the raw `ToolCatalog`, which the core exposes at `r.tools.catalog`. A local server spawned as a subprocess uses the stdio transport instead. Everything else stays the same: ```ts import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; const handle = await registerMcpServer(r.tools.catalog, { name: "fs", transport: new StdioClientTransport({ command: "npx", args: ["-y", "@modelcontextprotocol/server-everything"], }), }); ``` Any [transport from `@modelcontextprotocol/sdk`](https://modelcontextprotocol.io) works: Streamable HTTP, stdio, or SSE. A failure during connect, `tools/list`, or registration rejects the returned promise — and closes the SDK-owned client first, so no connection leaks. The options object is `RegisterMcpServerOptions = { name, transport }`; the promise resolves to an `McpServerHandle = { toolIds, serverInstructions, close }`. Registration wraps connect, `tools/list`, and ingest in one `ratel.upstream.register` span and records an `upstream_register` trace event. ## Namespaced ids and the handle Each upstream tool registers under **`__`**: `fs__read_file`, `github__create_issue`. The prefix keeps ids collision-free across servers, and it is how `search_capabilities` groups results by server (everything before the first `__`). An upstream tool with no description registers with `""`, leaving only its name and schema text to rank on. Worth fixing upstream or documenting around. The returned handle carries three things: * `toolIds`: the namespaced ids that were registered — every `tools/list` page, in upstream order. * `serverInstructions`: the upstream's instructions, read from the live handshake. * `close()`: closes the SDK-owned MCP client; call it when your process winds down. ## Paginated `tools/list` `registerMcpServer` follows `nextCursor` until the server omits it. Per MCP, `""` is a valid cursor — only an **absent** `nextCursor` ends pagination. A repeated cursor or a server that never stops (the cap is 64 pages) throws `McpToolsListError`, with a stable `code` (`McpToolsListErrorCode`): `"RepeatedCursor"` or `"PaginationExceeded"`. A broken paginator is a typed failure, not a hang. ## Error propagation An upstream `tools/call` failure records an `upstream_error` trace event and rethrows, so it propagates as a rejected promise from `r.tools.invoke`, the same handling path as a local executor that throws. When the model reaches the tool through `invoke_tool`, that failure comes back as a structured `{ "error": "tool threw: ...", "isError": true }` instead, so the loop stays recoverable. ## Many servers, one ranked surface Call `registerMcpServer` once per server and keep registering local tools on the same core: the model sees a single ranked surface through the same [capability tools](https://www.ratel.sh/docs/progressive-disclosure-tools) from `r.modelTools()`. To advertise the aggregation in the search tool's description, assemble it yourself with the piecemeal `searchCapabilitiesTool` factory, which accepts the upstream list: ```ts const search = searchCapabilitiesTool(r.tools.catalog, r.skills, { upstreamServers: [{ name: "gmail", description: "Send and search email", toolCount: 12 }], }); // UpstreamServerInfo = { name, description?, instructions?, toolCount?, needsAuth? } ``` The second argument is an optional [`SkillCatalog`](https://www.ratel.sh/docs/typescript/register-skills) — here the core's own `r.skills`. Each entry renders one line in the tool's description, via the exported `formatUpstreamLine` helper, as `- — ( tools) (auth required)` with every part after the name conditional. The matching entry also fills a result group's `server.description` and `server.instructions`. ## Embeddings for upstream tools Upstream tools enter the same corpus as local ones, so the [embedding cache](https://www.ratel.sh/docs/typescript/register-tools#the-embedding-cache) treats them identically. On a semantic or hybrid core, `await registerMcpServer(...)` embeds every ingested tool as part of registration: budget a model load plus one embedding per tool on the first ingest. A BM25 core holds no vectors and cannot be retrofitted. Construct it as `ratel({ method: "hybrid", embedding: … })` (or `"semantic"`) to rank ingested tools that way. A re-sync that re-registers an upstream tool re-embeds it at `register` on a semantic/hybrid core. What the engines cost is on [Semantic & hybrid search](https://www.ratel.sh/docs/retrieval-and-search#semantic-and-hybrid-search). ## Next steps [Register skills](https://www.ratel.sh/docs/typescript/register-skills) Add Markdown playbooks that rank in their own corpus, next to the tools. [Use the discovery tools](https://www.ratel.sh/docs/typescript/discovery-tools) How search\_capabilities groups results per server and invoke\_tool handles upstream auth. [Ratel Local](https://www.ratel.sh/docs/local/mcp-servers) Aggregate upstream servers with Ratel Local instead of in your own code. --- # Register skills Source: https://www.ratel.sh/docs/typescript/register-skills Skills are Markdown playbooks (a deploy runbook, a debugging checklist) ranked in a *separate* corpus from tools, so a relevant skill is never crowded out by matching tools. The concept is covered in [Tools, MCP servers & skills](https://www.ratel.sh/docs/tools-mcp-skills); Ratel's own coding-agent skills (including `ratel-decompose-prompt`, which extracts skills from a long system prompt) are on the [Skillset](https://www.ratel.sh/docs/skillset). ## Register a skill A skill can declare the `tools` its instructions call: when the skill matches a query, those tools are pulled into the `tools` bucket (additively, deduped) so the agent gets the playbook *and* the tools it needs in one turn instead of a second search. ```ts import { ratel } from "@ratel-ai/sdk"; const r = ratel(); await r.skills.register({ id: "vercel-deploy", name: "vercel-deploy", description: "How to deploy to Vercel: env vars, preview vs production, rollbacks.", tags: ["deploy", "ship to production"], // indexed for ranking tools: ["vercel__deploy", "fs__read_file"], // surfaced alongside the skill when it matches metadata: { stacks: ["next", "vercel"] }, // non-indexed context for higher-layer ranking body: "## Deploying to Vercel\n1. ...", // returned by get_skill_content }); const tools = r.modelTools(); // search_capabilities now returns a populated `skills` bucket ``` Only `id`, `name`, and `description` are required; `tags`, `tools`, `metadata`, and `body` are optional (parity with the Python SDK). Ranking indexes a skill's name, description, and tags; the body never enters the index, so a long playbook cannot drown out its own description. ## How skills surface Nothing to wire: `search_capabilities` from `r.modelTools()` already searches `r.skills`, so results carry a `skills` bucket alongside `tools`, each with its own result budget. The agent pulls a skill's full body into context on demand via `get_skill_content` (constant `GET_SKILL_CONTENT_ID`). `get_skill_content({ skillId })` returns `{ body }`, or `{ error, isError: true }` for an unknown id. Assembling the funnel piecemeal instead? Pass the `SkillCatalog` as the second argument to `searchCapabilitiesTool` to populate the `skills` bucket, and build the loader with `getSkillContentTool(skills)` (id `get_skill_content`). ## The `r.skills` surface `r.skills` is the shared `SkillCatalog` itself, exposed raw — no guarded wrapper (unlike `r.tools`), so the whole surface is on the handle and `search` top-K is not clamped: ```ts r.skills.search(query, topK); // → SkillHit[] ({ skillId, score, rank, fused }); synchronous, BM25-only await r.skills.searchAsync(query, topK); // → Promise; any method, (query, topK, origin?, method?) r.skills.has(skillId); // → boolean r.skills.get(skillId); // → Skill | undefined r.skills.size(); // → number of registered skills r.skills.invoke(skillId); // → string: the Markdown body; throws on unknown id r.skills.replaceAll(skills); // → PendingReplace: the batch becomes the whole catalog r.skills.recordEvent(event); // inject a trace event into the active sink r.skills.drainTraceEvents(); // → unknown[]; memory sink only ``` Order hits by `rank` (0-based, stable across methods) and branch on `fused`: `true` means `score` is an RRF value (hybrid search, or the experimental adaptive usage arm), not a raw BM25 or cosine score. `invoke` returns the body directly (`body ?? ""`), recording a `skill_invoke` trace event, and throws `unknown skillId: ` for an unknown id; the `get_skill_content` capability tool is the boundary that translates that throw into the structured `{ error, isError: true }` the agent sees. The catalog's options (`SkillCatalogOptions = { trace?, method?, embedding? }`, the same as `ToolCatalog`'s) come from `RatelConfig`: `ratel({ method, embedding, trace })` forwards all three to both catalogs. Constructing standalone remains the piecemeal path — `new SkillCatalog(options)` takes them directly. ## Replace the whole catalog `replaceAll` makes the batch the entire catalog ([ADR 0015](https://github.com/ratel-ai/ratel/blob/main/docs/adr/0015-whole-catalog-skill-reload.md)): ids absent from it are removed, including skills registered in-process. It exists for a source that fetches the full catalog rather than pushing deltas; a host that keeps local skills alongside a remote source composes the batch itself: ```ts const reload = r.skills.replaceAll([...localSkills, ...remoteSkills]); reload.added; // counts (added / removed / updated / unchanged) are final before awaiting await reload; // drives the embedding pass — always await (or .catch()) ``` It mutates the one shared `SkillCatalog` in place, so `r.skills`, every adapted view, and the capability tools from `modelTools()` all observe the reload — and `modelTools()` pins the `search_capabilities` description, so a reload cannot bust the prompt cache. Two-phase, exactly like `register`: the corpus swap commits synchronously; only the embedding pass rides the returned `PendingReplace` (`Promise & ReplaceOutcome`). The counts stay readable even when that pass fails — but it is a real promise, so leaving it unhandled turns an embedding failure into an unhandled rejection. On a failed pass the new corpus is already live and BM25 ranks it; semantic search reports `EmbeddingsNotBuilt` until a later pass succeeds. A reload racing an in-flight operation — dense work, but also a plain `searchAsync` — throws `registry busy` at the call site rather than blending; retry it. Only new or re-worded skills are embedded, so reloading an unchanged catalog costs zero embedding calls. `PendingReplace` and `ReplaceOutcome` are exported from `@ratel-ai/sdk`. The tool corpus has no equivalent: `register` stays the only way into a `ToolCatalog`. ## Embeddings for skills The skill corpus has its own embedding cache, maintained exactly like the tool one ([The embedding cache](https://www.ratel.sh/docs/typescript/register-tools#the-embedding-cache)): create the core with `ratel({ method: "semantic" })` or `"hybrid"` so `await r.skills.register(...)` embeds each skill; a BM25 catalog cannot be retrofitted. The two caches stay independent, but `ratel()` gives both catalogs the same method and [embedding model](https://www.ratel.sh/docs/embedding-models) — to mix (say, hybrid skills over BM25 tools), construct `new SkillCatalog(...)` piecemeal. Embeddings cover the same projection the ranking indexes (name, description, tags), never the body, so a long playbook costs one small embedding. When the engines are worth it is on [Semantic & hybrid search](https://www.ratel.sh/docs/retrieval-and-search#semantic-and-hybrid-search). ## Next steps [Use the discovery tools](https://www.ratel.sh/docs/typescript/discovery-tools) The skills bucket in search\_capabilities results, and loading a body via get\_skill\_content. [Skillset](https://www.ratel.sh/docs/skillset) Coding-agent skills that extract, tune, and maintain the skills your agents load. [Tools, MCP servers & skills](https://www.ratel.sh/docs/tools-mcp-skills) Where skills sit next to tools and MCP servers in the Ratel model. --- # Use the discovery tools Source: https://www.ratel.sh/docs/typescript/discovery-tools `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](https://www.ratel.sh/docs/progressive-disclosure-tools). ```ts 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](https://www.ratel.sh/docs/typescript/framework-integrations) 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: ```jsonc { "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`: `CapabilityToolHit`s grouped per server (`CapabilityToolGroup`), plus `CapabilitySkillHit`s. `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](https://www.ratel.sh/docs/typescript/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? }`, inferring the upstream server from the `__` id. To be notified before that result returns — say, to kick off a re-auth flow — build the tool through the [piecemeal factory](#the-piecemeal-factories). ## `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. ```ts 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](https://www.ratel.sh/docs/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: ```ts // 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](https://www.ratel.sh/docs/typescript/framework-integrations) 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`): ```ts // 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](https://www.ratel.sh/docs/typescript/semantic-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](https://www.ratel.sh/docs/retrieval-and-search#keyword-search), the semantic and hybrid engines (and the local embedding model's footprint) in [Semantic & hybrid search](https://www.ratel.sh/docs/retrieval-and-search#semantic-and-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: ```ts 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 `ExecutableTool`s. Two behaviors `modelTools()` otherwise handles for you: * **Skills gating.** `searchCapabilitiesTool`'s `skills` bucket is always present but stays empty until you pass a [`SkillCatalog`](https://www.ratel.sh/docs/typescript/register-skills) as 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 with `SearchCapabilitiesOptions.advertiseSkills` (0.5.1+) — what `modelTools()` 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](https://www.ratel.sh/docs/typescript/register-mcps). * **Auth callback.** `invokeToolTool(catalog, { onUnauthorized })` (`InvokeToolToolOptions`) notifies you, sync or async, before the `needs_auth` result 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](https://www.ratel.sh/docs/typescript/framework-integrations) r.adaptTo(...) exposes the capability tools in framework shape, recall included. [Progressive disclosure tools](https://www.ratel.sh/docs/progressive-disclosure-tools) The full contract search\_capabilities, invoke\_tool, and get\_skill\_content expose to the model. [Semantic & hybrid search](https://www.ratel.sh/docs/retrieval-and-search#semantic-and-hybrid-search) When to opt in to embedding-based retrieval, and what it costs. --- # Semantic & hybrid search Source: https://www.ratel.sh/docs/typescript/semantic-search BM25 is the default and needs none of this page. When paraphrase queries keep missing, opt into the dense engines: fix the method on `ratel()`, `await r.tools.register(...)` to embed, search with `searchAsync`. How the engines rank is on [Retrieval and search](https://www.ratel.sh/docs/retrieval-and-search#semantic-and-hybrid-search); the model and its sources are on [Embedding models](https://www.ratel.sh/docs/embedding-models). ## Opt in and embed The method is fixed at construction, and `register` is the only thing that embeds: ```ts import { ratel } from "@ratel-ai/sdk"; const r = ratel({ method: "hybrid", // every search defaults to hybrid // embedding: { huggingface: "..." } // optional non-default model }); // One batch, one embedding request. Metadata is indexed synchronously; // the await drives the embedding pass on a libuv worker, so the event // loop is never blocked. await r.tools.register(...tools); ``` * `register` is variadic — spread the whole batch into one call: separate `register` calls embed separately. * Re-registering an id replaces it in place and costs one embedding, not N. * A `"bm25"` core resolves as soon as metadata is indexed and never loads a model. * A model or dimension change is not recovered in place: construct a new core and re-register. The cache lifecycle is on [The embedding cache](https://www.ratel.sh/docs/typescript/register-tools#the-embedding-cache). Assembling the layers yourself? `new ToolCatalog({ method, embedding })` takes the same options — `r.tools` is a guarded handle over that shared catalog, reachable at `r.tools.catalog`. ## Configure another model Omit `embedding` and the default `BAAI/bge-small-en-v1.5` runs in-process, keyless. To use another model, name the source by its key — one of four: ```ts // HuggingFace repo — in-process via Candle, like the default. ratel({ method: "hybrid", embedding: { huggingface: "BAAI/bge-base-en-v1.5" } }); // Local checkpoint — same in-process engine, air-gapped. A bare path also works. ratel({ method: "hybrid", embedding: { local: "/opt/models/bge" } }); // Ollama — the local server at http://localhost:11434. ratel({ method: "hybrid", embedding: { ollama: "nomic-embed-text" } }); // OpenAI-compatible endpoint — the route for non-BERT or multilingual models. ratel({ method: "hybrid", embedding: { url: "https://api.openai.com/v1/embeddings", model: "text-embedding-3-small", apiKeyEnv: "OPENAI_API_KEY", // named env var, read at call time }, }); ``` One `embedding` covers both corpora: `ratel()` forwards it to the tool and skill catalogs alike. Which model each source loads, downloads, and how it fails is on [Embedding models](https://www.ratel.sh/docs/embedding-models). ## Search any method `r.tools.searchAsync(query, topK, method?)` runs any method off the event loop; `method` overrides the core default for that call: ```ts const hits = await r.tools.searchAsync("rotate the api key", 5); const bm25 = await r.tools.searchAsync("rotate the api key", 5, "bm25"); ``` Synchronous `r.tools.search` stays BM25-only: a `"semantic"`/`"hybrid"` method, passed or inherited from the core default, throws immediately with guidance to use `searchAsync`. A search never embeds the corpus; on an unembedded corpus a dense search fails fast and the guard loads no model — `await r.tools.register(...)` first. `search_capabilities` always ranks with the construction-time default ([Use the discovery tools](https://www.ratel.sh/docs/typescript/discovery-tools)). ## Handle embedding failures Every dense failure from `await r.tools.register(...)` or `await r.tools.searchAsync(...)` is a typed `EmbedderError`. Branch on `instanceof` or its stable `code`, never on message text: ```ts import { DimensionMismatchError, EmbedderError } from "@ratel-ai/sdk"; try { await r.tools.register(...tools); } catch (err) { if (err instanceof DimensionMismatchError) { // the model changed under an existing corpus: new core + re-register } else if (err instanceof EmbedderError) { console.error(err.code, err.message); // e.g. "NotCached", "Download" } else { throw err; } } ``` * The codes: `"Load"`, `"Download"`, `"NotCached"`, `"ModelMismatch"`, `"DimensionMismatch"`, `"EmbeddingsNotBuilt"`, `"Inference"`, `"CacheUnwritable"`. * `"EmbeddingsNotBuilt"` on a corpus you did register is the signature of a forgotten `await`: await the `register` promise before a dense search. * Invalid embedding *config* is not an `EmbedderError`: it throws a plain `Error` at construction, before any model loads. ## Skills rank the same way The same core covers skills: `ratel()` forwards `method` and `embedding` to the shared `SkillCatalog`, `await r.skills.register(...)` embeds, `r.skills.searchAsync(query, topK, origin?, method?)` ranks. The tool and skill caches are independent ([Register skills](https://www.ratel.sh/docs/typescript/register-skills#embeddings-for-skills)). ## Next steps [Embedding models](https://www.ratel.sh/docs/embedding-models) Swap the default model for another HuggingFace repo, a local checkpoint, Ollama, or an endpoint. [Retrieval and search](https://www.ratel.sh/docs/retrieval-and-search#keyword-vs-semantic-vs-hybrid) When each engine is worth it, and why better definitions come first. [Use the discovery tools](https://www.ratel.sh/docs/typescript/discovery-tools) How the capability tools inherit the catalog's retrieval method. --- # Framework integrations Source: https://www.ratel.sh/docs/typescript/framework-integrations Ratel ships official adapters for the Vercel AI SDK and Mastra ([ADR 0013](https://github.com/ratel-ai/ratel/blob/main/docs/adr/0013-framework-adapter-spi.md)). Each layers a framework-shaped view over the same framework-neutral core: your agent registers its own tools, the model sees only the three capability tools (`search_capabilities` / `invoke_tool` / `get_skill_content`), and Ratel injects a ranked per-turn recall — no conversion glue in app code. Adapters are near-pure glue: the framework and `@ratel-ai/sdk` are peers you already have. The Mastra adapter has zero runtime dependencies; the Vercel adapter carries exactly one — `@ratel-ai/telemetry`, the zero-dependency `ratel.*` constants package its `./otel` entrypoint imports — plus two optional peers, `@ai-sdk/otel` and `@opentelemetry/api`, needed only for `./otel`. Using Pydantic AI? See the [Python framework integrations](https://www.ratel.sh/docs/python/framework-integrations). ## Vercel AI SDK ```bash npm install @ratel-ai/sdk @ratel-ai/vercel-ai-sdk ``` Adapt the core with [`aiSdk()`](https://www.ratel.sh/docs/packages/typescript/vercel-ai-sdk) and your existing `tool()`s register unchanged: ```ts import { ratel } from "@ratel-ai/sdk"; import { aiSdk } from "@ratel-ai/vercel-ai-sdk"; import { streamText, tool } from "ai"; import { z } from "zod"; const r = ratel({ method: "hybrid", recallTopK: 5 }).adaptTo(aiSdk()); // Your existing AI SDK tools — registered once instead of sent every turn. await r.tools.register({ weather: tool({ description: "Get the weather in a location", inputSchema: z.object({ location: z.string() }), execute: async ({ location }) => ({ location, tempF: 72 }), }), }); const result = streamText({ model, // your model, unchanged tools: r.modelTools(), // the three capability tools — take once, reuse across turns messages: await r.appendRecall(messages), // inject this turn's ranked recall }); ``` Two notes on the loop: * **Persist the response messages** between turns (`await result.responseMessages` on `ai@7`; `(await result.response).messages` on `ai@5`/`ai@6`) — recall fires only when the last message is the user's turn. `appendRecall` suffix-appends into your durable history, so the recall pair extends the cached prompt prefix across turns. * Prefer not to store recall pairs? `prepareStep: r.prepareStep` injects the same pair as a step-0 override on a single call and never touches stored history — the lighter drop-in for one-shot or stateless calls. Compatibility: `ai@^5.0.0 || ^6.0.0 || ^7.0.0`, one shared code path; `ai@4` predates the v5 tool/message reshape and is excluded. Needs `@ratel-ai/sdk` 0.6.x (peer `^0.6.0` — staying on the 0.5 SDK means adapter 0.2.0). npm resolves peers, so a mismatch fails at install time with `ERESOLVE`; if the unpinned install above hits it, pin `@ratel-ai/sdk@0.6`. ### Telemetry On `ai@7`, register `RatelOtelIntegration` from `@ratel-ai/vercel-ai-sdk/otel`. It embeds `@ai-sdk/otel`'s emitter and stamps the `ratel.origin` overlay on every `gen_ai.*` span through the emitter's `enrichSpan` hook — the AI SDK's standard spans plus the `ratel.*` overlay. It only *creates* spans, onto the provider your app owns — it never registers a provider and never exports, so any processor already on that provider (Langfuse, a generic OTLP exporter) receives them. [Telemetry & OTel](https://www.ratel.sh/docs/typescript/telemetry) covers the host-owned provider setup. * Options are `@ai-sdk/otel`'s `OpenTelemetryOptions` plus `origin` — the `ratel.origin` value, default `agent`. Right for an agent's tool-loop spans; set it yourself for host-driven `embed` / `embedMany` / `rerank`. * Your own `enrichSpan` still runs; its attributes merge under Ratel's — everything lands except `ratel.origin` itself. * Register exactly **one** emitting integration. This one, Langfuse's, and the bare `OpenTelemetry` all embed the same emitter, so two would duplicate every `gen_ai.*` span. * `Origin` is re-exported from the entrypoint, so you never depend on `@ratel-ai/telemetry` directly. * `./otel` needs the optional peers `@ai-sdk/otel` and `@opentelemetry/api` — install them yourself. They stay optional (and the entrypoint off-root) on purpose: `@ai-sdk/otel` depends on an exact `ai@7`, and a second `ai` in an `ai@5`/`ai@6` type graph breaks the build (TS2403). On `ai@5`/`ai@6` there is no integration seam — pass `experimental_telemetry: { isEnabled: true }` per call instead. ## Mastra ```bash npm install @ratel-ai/sdk @ratel-ai/mastra ``` Adapt the core with [`mastra()`](https://www.ratel.sh/docs/packages/typescript/mastra) and your existing `createTool()`s register unchanged; recall rides an input processor: ```ts import { Agent } from "@mastra/core/agent"; import { createTool } from "@mastra/core/tools"; import { ratel } from "@ratel-ai/sdk"; import { mastra } from "@ratel-ai/mastra"; import { z } from "zod"; const r = ratel({ method: "hybrid", recallTopK: 5 }).adaptTo(mastra()); // Your existing Mastra tools — registered once instead of listed on the agent. await r.tools.register({ weather: createTool({ id: "weather", description: "Get the weather in a location", inputSchema: z.object({ location: z.string() }), execute: async ({ location }) => ({ location, tempF: 72 }), }), }); const agent = new Agent({ name: "assistant", instructions: "Help the user with their tasks.", model: "openai/gpt-4o-mini", tools: r.modelTools(), // take once per agent — the set never changes inputProcessors: [r.recallProcessor()], // per-turn recall injection }); const result = await agent.generate("what's the weather in Paris?"); ``` `r.recallProcessor()` returns a fresh Mastra `Processor` per call (one per agent). It runs once at the start of every generation: when the last message is the user's turn, it ranks the catalog and appends the synthetic `search_capabilities` pair to the messages the model sees — never re-injected during the tool-call loop. Compatibility: `@mastra/core@>=1.11.0 <2` on Node.js 22.13+. Needs `@ratel-ai/sdk` 0.6.x (peer `^0.6.0`; SDK 0.6 is additive over 0.5.2 — staying on the 0.5 SDK means adapter 0.1.0). npm resolves peers, so a mismatch fails at install time with `ERESOLVE`; if the unpinned install above hits it, pin `@ratel-ai/sdk@0.6`. ## Any other framework (hand-rolled) LangChain, OpenAI Agents, a custom loop: everything the SDK hands you is a plain executable tool, so the two-piece recipe applies — an adapter to the framework's tool type, plus a turn assembler that exposes `r.modelTools()` and injects recall ([ADR 0004](https://github.com/ratel-ai/ratel/blob/main/docs/adr/0004-retrieval-and-tool-selection.md) replace-mode pre-filtering). [`examples/mcp-chat`](https://github.com/ratel-ai/ratel/blob/main/examples/mcp-chat/README.md) shows the recipe over an ingested MCP server ([`registerMcpServer`](https://www.ratel.sh/docs/typescript/register-mcps)). To build a real adapter, implement the `RatelAdapter` SPI — three codecs (`ingest`, `expose`, `recallMessages`) plus an optional `extend` — and run the conformance battery from `@ratel-ai/sdk/testkit` (`adapterConformanceCases` / `describeAdapterConformance`, with `referenceAdapter` as the worked example). [Progressive disclosure tools](https://www.ratel.sh/docs/progressive-disclosure-tools) covers the contract the capability tools expose to the model. ## Next steps [Register tools](https://www.ratel.sh/docs/typescript/register-tools) The r.tools surface and the catalog behind the capability funnel. [Telemetry & OTel](https://www.ratel.sh/docs/typescript/telemetry) See the invoke and gateway events these integrations emit, locally and over OpenTelemetry. --- # Telemetry & OTel Source: https://www.ratel.sh/docs/typescript/telemetry Ratel emits telemetry on two deliberately separate streams ([ADR 0007](https://github.com/ratel-ai/ratel/blob/main/docs/adr/0007-telemetry-two-streams.md)): **local trace events** recorded into a sink you configure, and **OpenTelemetry spans** exported as stock OTLP. Both are off by default. This page is the TypeScript wiring. The full event-type vocabulary and span table live on [Telemetry](https://www.ratel.sh/docs/observability). ## Local trace sinks Pass `trace` to `ratel()` and the core records every search, invoke, capability-tool, upstream-MCP, and auth event into the sink. The one config is forwarded to both internal catalogs, so tool and skill events land in the same sink. ```ts import { ratel } from "@ratel-ai/sdk"; const r = ratel({ trace: { kind: "jsonl", sessionId: "session-1", path: "/tmp/ratel.jsonl" }, }); // Every r.tools.invoke, capability-tool call, and registerMcpServer call now appends one JSON line per event to /tmp/ratel.jsonl. ``` `TraceSinkConfig` is a discriminated union: `{ kind: "noop" }`, `{ kind: "memory"; sessionId }`, or `{ kind: "jsonl"; sessionId; path }`. Misconfiguration throws (for example `"jsonl sink requires path"`). * `{ kind: "noop" }`: drop everything (the default when no `trace` is passed). * `{ kind: "memory"; sessionId }`: keep events in memory; drain via `r.tools.catalog.drainTraceEvents()`. Useful in tests. * `{ kind: "jsonl"; sessionId; path }`: append one JSON line per event to `path` (mode `0600` on Unix). Best-effort, lossy on backpressure. Recording and draining live on the raw catalogs — `r.tools.catalog` (the `ToolCatalog` under the handle) and `r.skills` (the raw `SkillCatalog`). `recordEvent(event)` injects an event into the active sink, validating it against the core trace schema: an unrecognized shape throws `invalid trace event: ...`. `drainTraceEvents()` removes and returns the captured envelopes, `[]` unless the active sink is `memory`. `search_capabilities` records its own `gateway_search` event and tags the underlying catalog search's `search` event with `origin: "agent"`; direct callers (`r.tools.search(query, k)`) default to `"direct"`. Override per call on the raw catalog: `r.tools.catalog.search(query, k, "agent")`. The full event set is in [Telemetry](https://www.ratel.sh/docs/observability). Assembling the funnel piecemeal? `new ToolCatalog({ trace: ... })` and `new SkillCatalog({ trace: ... })` take the same config directly, and the `searchCapabilitiesTool` factory emits the same `gateway_search` event. ## OpenTelemetry export Independently of the local sink, the SDK emits OpenTelemetry spans for the same funnel: `execute_tool`, `ratel.search`, `ratel.skill.load`, `ratel.upstream.register`, `ratel.auth.flow` (the `gen_ai.*` / `ratel.*` vocabulary, catalogued in [Telemetry](https://www.ratel.sh/docs/observability)). Under an event [content-capture](#content-capture) mode, gated payloads also go out as OpenTelemetry Logs `EventRecord`s. The SDK's OpenTelemetry dependencies are API-only — `@opentelemetry/api` and `@opentelemetry/api-logs` — never the OTel SDK. It registers no provider of its own: until you register one, every span and `EventRecord` is a no-op. The `ratel.*` semantic-convention vocabulary itself ships as the OTel-free package [`@ratel-ai/telemetry`](https://www.ratel.sh/docs/packages/typescript/telemetry). ### Greenfield: ship telemetry to Ratel Cloud No OpenTelemetry in your stack yet? The SDK ships no bootstrap: you build the providers, register them globally, and own flush and shutdown. Install the OTel SDK packages: ```bash npm install @opentelemetry/sdk-trace-node @opentelemetry/sdk-trace-base @opentelemetry/sdk-logs \ @opentelemetry/exporter-trace-otlp-proto @opentelemetry/exporter-logs-otlp-proto \ @opentelemetry/resources @opentelemetry/api-logs ``` Wire a `NodeTracerProvider` for the spans and a `LoggerProvider` for the content `EventRecord`s, each with an OTLP batch exporter. The Logs URL is the traces URL's sibling: replace the terminal `/v1/traces` with `/v1/logs`. ```ts // otel.ts — import this before any module that imports @ratel-ai/sdk import { logs } from "@opentelemetry/api-logs"; import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto"; import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto"; import { resourceFromAttributes } from "@opentelemetry/resources"; import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs"; import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base"; import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"; const endpoint = process.env.RATEL_OTLP_ENDPOINT ?? "https://cloud.ratel.sh/v1/traces"; const logsUrl = endpoint.replace(/\/v1\/traces$/, "/v1/logs"); const apiKey = process.env.RATEL_API_KEY; const headers = apiKey ? { Authorization: `Bearer ${apiKey}` } : {}; const resource = resourceFromAttributes({ "service.name": "my-agent" }); export const tracerProvider = new NodeTracerProvider({ resource, spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter({ url: endpoint, headers }))], }); export const loggerProvider = new LoggerProvider({ resource, processors: [new BatchLogRecordProcessor({ exporter: new OTLPLogExporter({ url: logsUrl, headers }) })], }); tracerProvider.register(); // every ratel.* / execute_tool span now exports logs.setGlobalLoggerProvider(loggerProvider); ``` * Register both providers before importing the SDK — keep the wiring in its own module and import it first. * `RATEL_OTLP_ENDPOINT` and `RATEL_API_KEY` are plain host configuration; no Ratel package reads them. Swap in your own config source — the API key rides as `Authorization: Bearer`. * You own shutdown: `await tracerProvider.shutdown()` and `await loggerProvider.shutdown()` on exit flush both batch processors. [`examples/telemetry-ts`](https://github.com/ratel-ai/ratel/tree/main/examples/telemetry-ts) is the runnable version of this wiring. > Already running a provider — Langfuse, the Vercel AI SDK, your own collector? Don't register a second one: [dual-export](#already-on-opentelemetry-dual-export) from the provider you have. ### Already on OpenTelemetry: dual-export If Langfuse, the Vercel AI SDK, or your own collector already owns the provider, the SDK's spans are already flowing to it. To send Ratel Cloud a copy, add a filtered span processor of your own — there is no packaged one: ```ts import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto"; import { BatchSpanProcessor, type ReadableSpan } from "@opentelemetry/sdk-trace-base"; import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"; import { LangfuseSpanProcessor } from "@langfuse/otel"; const isRatelSpan = (span: ReadableSpan) => span.name.startsWith("ratel.") || Object.keys(span.attributes).some((k) => k.startsWith("gen_ai.") || k.startsWith("ratel.")); class RatelSpanProcessor extends BatchSpanProcessor { onEnd(span: ReadableSpan) { if (isRatelSpan(span)) super.onEnd(span); } } const provider = new NodeTracerProvider({ spanProcessors: [ new LangfuseSpanProcessor(), // your existing destination new RatelSpanProcessor(new OTLPTraceExporter({ url: endpoint, headers })), // same endpoint/headers resolution as the greenfield snippet ], }); provider.register(); ``` The filter forwards a span if its name starts with `ratel.` or any attribute key starts with `gen_ai.` or `ratel.`; drop the `onEnd` override to forward everything. * Content `EventRecord`s need the Logs half too: add a `BatchLogRecordProcessor` + `OTLPLogExporter` at the sibling `/v1/logs` URL to the `LoggerProvider` you register, or [captured content](#content-capture) never reaches Ratel Cloud. * Check your existing destination's own filter: a stock `LangfuseSpanProcessor` drops the `ratel.*`-named spans (they carry no `gen_ai.*` attribute). Widen it with `shouldExportSpan`, keyed on scope name `@ratel-ai/sdk`. ### Content capture Message and tool content never leaves the process by default (`NO_CONTENT`). Opting in opens up to two channels: * Span modes (`SPAN_ONLY`, `SPAN_AND_EVENT`): the gated payloads (`ratel.search.query`, `gen_ai.tool.call.arguments` / `gen_ai.tool.call.result`) ride span attributes. * Event modes (`EVENT_ONLY`, `SPAN_AND_EVENT`): content rides structured OpenTelemetry Logs `EventRecord`s — `ratel.tool.execution.details` and `ratel.search.results` — through your registered `LoggerProvider`. `EVENT_ONLY` puts nothing on span attributes. Set the gate in code with `setContentCapture(mode)` (`ContentCapture`, `setContentCapture`, and `clearContentCapture` are re-exported from `@ratel-ai/sdk`); it wins over the `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` env var, the fallback when nothing is set in code. The full mode table is on [Telemetry](https://www.ratel.sh/docs/observability). ```ts import { ContentCapture, setContentCapture } from "@ratel-ai/sdk"; setContentCapture(ContentCapture.SpanAndEvent); ``` ## Next steps [Telemetry](https://www.ratel.sh/docs/observability) The full event-type vocabulary, span table, and content-capture modes. [Packages](https://www.ratel.sh/docs/typescript/packages) Raw API references and changelogs, @ratel-ai/telemetry included. --- # Packages Source: https://www.ratel.sh/docs/typescript/packages Ratel's TypeScript surface is five npm packages: | Package | What it is | | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [`@ratel-ai/sdk`](https://www.ratel.sh/docs/packages/typescript/sdk) | The TypeScript SDK: catalogs, capability tools, MCP ingestion | | [`@ratel-ai/vercel-ai-sdk`](https://www.ratel.sh/docs/packages/typescript/vercel-ai-sdk) | Vercel AI SDK adapter: `ratel().adaptTo(aiSdk())` — AI SDK-native tools and per-turn recall (`ai@5`–`7`), plus the `./otel` telemetry integration (`RatelOtelIntegration`, `ai@7`) | | [`@ratel-ai/mastra`](https://www.ratel.sh/docs/packages/typescript/mastra) | Mastra adapter: `ratel().adaptTo(mastra())` — `createTool` tools and per-turn recall via `recallProcessor()` (`@mastra/core` 1.11+) | | [`@ratel-ai/telemetry`](https://www.ratel.sh/docs/packages/typescript/telemetry) | OTel-free `ratel.*` semantic-convention vocabulary | | [`@ratel-ai/ratel-local`](https://www.ratel.sh/docs/packages/typescript/mcp-server) | The package that powers [Ratel Local](https://www.ratel.sh/docs/local): MCP serving, CLI configuration, and library embedding | Each Core package page carries the raw API reference generated from source doc comments, plus the package's changelog. The `@ratel-ai/ratel-local` page points to its separately maintained source and product documentation. For the cross-language map (the Rust core and the Python packages), see [Packages](https://www.ratel.sh/docs/packages). --- # Install Source: https://www.ratel.sh/docs/python/install One package, no service: ```bash pip install ratel-ai ``` The base SDK needs **Python 3.10 or newer** for the examples in these guides and has no runtime dependencies. ## Prebuilt wheels The SDK bundles Ratel's Rust core as a native module. Prebuilt `abi3` wheels ship for: * macOS: `darwin-arm64`, `darwin-x64` * Linux (glibc): `linux-x64-gnu`, `linux-arm64-gnu` * Windows: `win32-x64-msvc` On these targets nothing compiles at install time and no Rust toolchain is needed. ## Extras * **`mcp`**: `pip install 'ratel-ai[mcp]'` pulls the official MCP client for [`register_mcp_server`](https://www.ratel.sh/docs/python/register-mcps). Lazily imported: the base SDK installs and runs without it. * **`otlp`**: `pip install 'ratel-ai[otlp]'` pulls the `ratel.*` span vocabulary and the OTLP exporter behind `configure_telemetry()`. See [Telemetry & OTel](https://www.ratel.sh/docs/python/telemetry). * **Semantic & hybrid retrieval**: not an extra. Opting in downloads the default local [embedding model](https://www.ratel.sh/docs/embedding-models) on first use; see [The embedding cache](https://www.ratel.sh/docs/python/register-tools#the-embedding-cache) and [Semantic & hybrid search](https://www.ratel.sh/docs/retrieval-and-search#semantic-and-hybrid-search). ## Next steps [Quickstart](https://www.ratel.sh/docs/python/quickstart) Register a tool and let an agent discover it through the capability tools. [Packages](https://www.ratel.sh/docs/python/packages) The two PyPI packages behind the Python surface. --- # Quickstart Source: https://www.ratel.sh/docs/python/quickstart This is the Python path. TypeScript reader? Start at the [TypeScript quickstart](https://www.ratel.sh/docs/typescript/quickstart). Rather not write the integration yourself? Point your coding agent at the [skills suite](https://www.ratel.sh/docs/skillset). Ratel is framework-agnostic: everything the SDK hands you is a plain executable tool (metadata plus an `execute` handler) that plugs into any agent loop. This page shows the two ways in: [starting from scratch](#start-from-scratch) and [migrating an existing agent](#migrate-an-existing-agent). > **Before you start** > > Use Python 3.10 or newer. Nothing on this page calls a model, so no API key is needed. ## How it works Everything starts with a **`ToolCatalog`**: register each of your tools once, pairing its metadata (id, description, JSON schemas) with the handler that runs it. A **`SkillCatalog`** holds Markdown playbooks the same way, ranked in their own corpus. The agent reaches both through the three capability tools: `search_capabilities` (find tools and skills by description), `invoke_tool` (run a tool by id), and `get_skill_content` (load a skill's body), so the full catalog never enters the prompt. How the pieces fit per turn is covered in [Architecture](https://www.ratel.sh/docs/architecture). ## Start from scratch ### Create a project and install the SDK ```bash mkdir ratel-quickstart && cd ratel-quickstart python -m venv .venv source .venv/bin/activate python -m pip install ratel-ai ``` Requirements, prebuilt wheels, and the `mcp` / `otlp` extras are on [Install](https://www.ratel.sh/docs/python/install). ### Register a tool and a skill Create `main.py`. Tools carry their metadata and handler; skills carry a description and a Markdown body: ```python import asyncio from ratel_ai import ( ExecutableTool, Skill, SkillCatalog, ToolCatalog, get_skill_content_tool, invoke_tool_tool, search_capabilities_tool, ) catalog = ToolCatalog() skills = SkillCatalog() async def build(): # register() is a coroutine; await it. Metadata is indexed synchronously; # the await drives embedding on a semantic/hybrid catalog. await catalog.register( ExecutableTool( id="get_weather", name="get_weather", description="Get the current weather for a city.", input_schema={ "type": "object", "properties": { "city": {"type": "string", "description": "City name"}, }, "required": ["city"], }, output_schema={ "type": "object", "properties": { "city": {"type": "string"}, "temperatureC": {"type": "number"}, "condition": {"type": "string"}, }, "required": ["city", "temperatureC", "condition"], }, execute=lambda args: { "city": args["city"], "temperatureC": 24, "condition": "sunny", }, ) ) await skills.register( Skill( id="weather-briefing", name="weather-briefing", description="How to report the weather: temperature first, then condition.", body="## Weather briefing\nAnswer in one sentence: temperature first, then the condition. No filler.", ) ) # The three capability tools are the only tools your agent needs to see. # Construct them after registering, so the skills bucket is advertised. return ( search_capabilities_tool(catalog, skills), # id: "search_capabilities" invoke_tool_tool(catalog), # id: "invoke_tool" get_skill_content_tool(skills), # id: "get_skill_content" ) ``` ### Run the discovery loop by hand Append the exact calls a model makes at runtime (capability tools own `async` handlers, so await `execute` directly), then run the file: ```python async def main() -> None: search, invoke, load = await build() print(await search.execute({"query": "weather in Rome"})) print(await invoke.execute({"toolId": "get_weather", "args": {"city": "Rome"}})) print(await load.execute({"skillId": "weather-briefing"})) asyncio.run(main()) ``` ```bash python main.py ``` Three results, no model key involved: * `search_capabilities` returns two independently-ranked buckets: `get_weather` under `tools`, `weather-briefing` under `skills`. Neither schema nor body entered any prompt to get there. * `invoke_tool` runs the handler and returns its result untouched: `{"city": "Rome", "temperatureC": 24, "condition": "sunny"}`. * `get_skill_content` returns the playbook: `{"body": "## Weather briefing\n…"}`. The full result shapes and error contract are on [Progressive disclosure tools](https://www.ratel.sh/docs/progressive-disclosure-tools). ### Hand the loop to your agent Each capability tool is a plain `ExecutableTool`: `id`, `name`, `description`, `input_schema`, `output_schema`, `execute`. Map those fields onto your framework's tool type (a few lines anywhere), add the three tools to the agent's tool list, and tell it to search before answering: ```text Find tools and playbooks with search_capabilities. Run tools with invoke_tool. When a skill matches, load it with get_skill_content and follow it. ``` Ready-made wiring, the Pydantic AI adapter and per-turn assembly, is on [Framework integrations](https://www.ratel.sh/docs/python/framework-integrations). Production agents usually add a top-K pre-filter next to the capability tools; that pattern (and the sync-vs-async dispatch rules) is on [Use the discovery tools](https://www.ratel.sh/docs/python/discovery-tools). ## Migrate an existing agent A migration changes where capabilities live, not your model or business logic. Start with one workflow, prove it still behaves the same, then repeat. | Existing asset | After migration | | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | | Tool IDs, schemas, and handlers | Register once in `ToolCatalog`; keep authorization and side effects in the handler. | | Conditional runbooks and examples | Register in `SkillCatalog`; load only when relevant. | | Identity, safety, universal policy, output rules, and runtime context | Keep in the core prompt. | | Model and agent loop | Keep both; reuse the loop's prompt and tool seams. | ### Find the integration points Find where your process creates long-lived dependencies and where each request builds the model's tool list. Create the catalogs at startup; change the model-facing list at the request boundary. ### Register an existing tool Tool definitions leave the repeated model request, not your code. Preserve the ID, schema contract, handler, authorization, permissions, and side effects. Convert framework-native schemas to JSON Schema at this boundary. ```python # Before: the schema is sent to the model on every request tools_for_every_request = [existing_refund_tool] # After: the same contract is registered once at startup catalog = ToolCatalog() asyncio.run(catalog.register( ExecutableTool( id="issue_refund", name="issue_refund", description="Issue an approved refund for an order.", input_schema=issue_refund_input_schema, output_schema=issue_refund_output_schema, execute=issue_refund, # the existing handler ) )) ``` Register each tool in this workflow the same way. During a gradual migration, leave unmigrated tools in the old list and remove each migrated definition from it. ### Extract an on-demand skill Keep instructions and runtime context that must govern every turn in the core prompt. Move only conditional guidance, such as the refund workflow, into a self-contained skill: ```python # Before: the refund workflow is sent on every turn instructions = "\n\n".join( [CORE_PROMPT, SAFETY_POLICY, OUTPUT_RULES, RUNTIME_CONTEXT, REFUND_RUNBOOK] ) # After: everything except the runbook stays in the core prompt core_instructions = "\n\n".join( [CORE_PROMPT, SAFETY_POLICY, OUTPUT_RULES, RUNTIME_CONTEXT] ) skills = SkillCatalog() asyncio.run(skills.register( Skill( id="refund-runbook", name="refund-runbook", description="Check refund eligibility, obtain approval, then issue the refund.", tools=["issue_refund"], body=REFUND_RUNBOOK, ) )) ``` The description says when to load the skill. Its `tools` list surfaces `issue_refund` beside the matched playbook. ### Rewire and verify the agent Add the discovery policy to the core prompt. After both catalogs are populated, build the three Ratel capability tools and adapt them at the request boundary: ```python discovery_instructions = """ Find tools and playbooks with search_capabilities. Run tools with invoke_tool. When a skill matches, load it with get_skill_content and follow it. """.strip() instructions = "\n\n".join([core_instructions, discovery_instructions]) capability_tools = [ search_capabilities_tool(catalog, skills), invoke_tool_tool(catalog), get_skill_content_tool(skills), ] ``` The capability tools are plain `ExecutableTool` values. [Framework integrations](https://www.ratel.sh/docs/python/framework-integrations) shows the adapter and the production top-K turn assembler. A refund request should now follow this path: 1. `search_capabilities` returns `refund-runbook` and `issue_refund`. 2. `get_skill_content` loads the eligibility and approval steps. 3. `invoke_tool` calls the unchanged `issue_refund` handler. 4. The agent answers from the tool result. Before migrating the next workflow, verify the skill ranks, its declared tool appears, the handler receives the same arguments, and the initial request excludes the full catalog and refund runbook. > **Let a coding agent do the migration** > > The [skills suite](https://www.ratel.sh/docs/skillset) ships skills for Claude Code, Cursor, Codex, and > similar: `ratel-integrate` plans this migration for your codebase, and > `ratel-decompose-prompt` splits the system prompt into skills. ## Next steps [Framework integrations](https://www.ratel.sh/docs/python/framework-integrations) Ready-made wiring for Pydantic AI, and the recipe for any other loop. [Skillset](https://www.ratel.sh/docs/skillset) Coding-agent skills that assess your codebase and apply this integration for you. [Register tools](https://www.ratel.sh/docs/python/register-tools) The anatomy of an executable tool, the full ToolCatalog surface, and ranking without execution. [Register skills](https://www.ratel.sh/docs/python/register-skills) The full SkillCatalog surface: tags, declared tools, and how skills rank. --- # Register tools Source: https://www.ratel.sh/docs/python/register-tools 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](https://www.ratel.sh/docs/python/register-mcps). ## 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: ```python 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](https://www.ratel.sh/docs/python/register-mcps) 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](https://www.ratel.sh/docs/retrieval-and-search#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: ```python 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](https://www.ratel.sh/docs/python/telemetry) (default: none), `method=` sets the catalog's default retrieval method (default `"bm25"`), and `embedding=` selects the [embedding model](https://www.ratel.sh/docs/embedding-models) for semantic/hybrid (default `bge-small-en-v1.5`). The per-call override lives on [Use the discovery tools](https://www.ratel.sh/docs/python/discovery-tools); how the engines rank is on [Semantic & hybrid search](https://www.ratel.sh/docs/retrieval-and-search#semantic-and-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: ```python 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](https://www.ratel.sh/docs/python/semantic-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](https://www.ratel.sh/docs/embedding-models). The default model's footprint, offline use, and when the engines are worth it are on [Semantic & hybrid search](https://www.ratel.sh/docs/retrieval-and-search#semantic-and-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: ```python 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](https://www.ratel.sh/docs/retrieval-and-search#semantic-and-hybrid-search), 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](https://www.ratel.sh/docs/python/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. ```python 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 [Register MCP servers](https://www.ratel.sh/docs/python/register-mcps) Ingest an upstream server's tools into the same ranked surface. [Use the discovery tools](https://www.ratel.sh/docs/python/discovery-tools) Expose the catalog to the model through search\_capabilities and invoke\_tool. [Keyword search](https://www.ratel.sh/docs/retrieval-and-search#keyword-search) How BM25 ranks the catalog, and how to write descriptions it finds. --- # Register MCP servers Source: https://www.ratel.sh/docs/python/register-mcps `register_mcp_server` lists an upstream server's tools and registers each one into the catalog, wiring its executor to the upstream `tools/call` over the same connection. It ships behind the `mcp` extra (Python 3.10+): ```bash pip install 'ratel-ai[mcp]' ``` ## Ingest an MCP server's tools You own the `ClientSession` lifecycle: set up the transport and session with `async with`, then pass the initialized session in. For a remote server, that transport is Streamable HTTP: ```python import asyncio from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client from ratel_ai import ToolCatalog, register_mcp_server async def main() -> None: catalog = ToolCatalog() async with streamablehttp_client("https://example.com/mcp") as (read, write, _): async with ClientSession(read, write) as session: init = await session.initialize() handle = await register_mcp_server( catalog, name="issues", session=session, transport_label="streamable-http", # recorded on upstream_register instructions=init.instructions, # echoed as server_instructions ) print(handle.tool_ids) # ["issues__create_issue", ...] asyncio.run(main()) ``` A local server spawned as a subprocess uses the stdio transport instead. The session handling stays the same: ```python from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client params = StdioServerParameters( command="npx", args=["-y", "@modelcontextprotocol/server-everything"] ) async with stdio_client(params) as (read, write): async with ClientSession(read, write) as session: init = await session.initialize() handle = await register_mcp_server( catalog, name="fs", session=session, transport_label="stdio", instructions=init.instructions, ) # handle.tool_ids → ["fs__echo", "fs__add", ...] ``` `catalog.search` and `catalog.invoke` now include the upstream tools. The third transport, `sse_client` (SSE), comes from the same `mcp` package. Because the caller owns the session, `transport_label` and `instructions` are caller-supplied: they default to `"unknown"` and `None`. > The caller-owns-the-session split is the one deliberate divergence from the TypeScript SDK, whose `registerMcpServer` reads the server instructions and transport label from the live handshake itself. Pass `instructions=` and `transport_label=` from your `await session.initialize()` result to make the emitted `upstream_register` event byte-identical across the two SDKs. ## Namespaced ids and the handle Each upstream tool registers under **`__`**: `fs__read_file`, `github__create_issue`. The prefix keeps ids collision-free across servers, and it is how `search_capabilities` groups results by server (everything before the first `__`). An upstream tool with no description registers with `""`, leaving only its name and schema text to rank on. That's worth fixing upstream or documenting around. The returned handle carries three things: * `tool_ids`: the namespaced ids that were registered — every `tools/list` page, in upstream order. * `server_instructions`: echoes whatever you passed as `instructions=`. * `close()`: an awaitable no-op by default (your `async with` blocks already own teardown); pass `on_close=` (an async callable) if the handle should tear something down itself. ## Paginated `tools/list` `register_mcp_server` follows `nextCursor` until the server omits it. Per MCP, `""` is a valid cursor — only an **absent** `nextCursor` ends pagination. The walk works across `mcp` client versions before and after 1.18, which changed how a cursor is passed. A repeated cursor or a server that never stops (the cap is 64 pages) raises `McpToolsListError`, exported from `ratel_ai`, with a stable `code`: `"RepeatedCursor"` or `"PaginationExceeded"`. A broken paginator is a typed failure, not a hang. ## Error propagation An upstream `tools/call` failure records an `upstream_error` trace event and rethrows, so it propagates as a raised exception from `catalog.invoke`, the same handling path as a local executor that throws. When the model reaches the tool through `invoke_tool`, that failure comes back as a structured `{ "error": "tool threw: ...", "isError": true }` instead, so the loop stays recoverable. ## Many servers, one ranked surface Call `register_mcp_server` once per server and keep registering local tools into the same catalog: the model sees a single ranked surface through the same [capability tools](https://www.ratel.sh/docs/progressive-disclosure-tools). To make the aggregation visible, pass the upstream list to the `search_capabilities` factory with the keyword-only `upstream_servers=`: a sequence of `UpstreamServerInfo(name, description=None, instructions=None, tool_count=None, needs_auth=False)`. The tool's description then names each aggregated server, and any result group whose server name matches gains that server's `description` and `instructions`. ## Embeddings for upstream tools Upstream tools enter the same corpus as local ones, so the [embedding cache](https://www.ratel.sh/docs/python/register-tools#the-embedding-cache) treats them identically. On a semantic- or hybrid-default catalog, `await register_mcp_server(...)` embeds every ingested tool as part of registration: budget a model load plus one embedding per tool on the first ingest. A BM25 catalog holds no vectors and cannot be retrofitted. Construct it with `"semantic"` or `"hybrid"` to rank ingested tools that way. A re-sync that re-registers an upstream tool re-embeds it at `register` on a semantic/hybrid catalog. What the engines cost is on [Semantic & hybrid search](https://www.ratel.sh/docs/retrieval-and-search#semantic-and-hybrid-search). ## Next steps [Register skills](https://www.ratel.sh/docs/python/register-skills) Add Markdown playbooks that rank in their own corpus, next to the tools. [Use the discovery tools](https://www.ratel.sh/docs/python/discovery-tools) Expose the aggregated catalog to the model, including auth-expiry handling. [Ratel Local](https://www.ratel.sh/docs/local/mcp-servers) Aggregate upstream servers with Ratel Local instead of in your own code. --- # Register skills Source: https://www.ratel.sh/docs/python/register-skills Skills are Markdown playbooks (a deploy runbook, a debugging checklist) ranked by a *separate* BM25 corpus from tools. What a skill is and when to reach for one is on [Tools vs MCP vs skills](https://www.ratel.sh/docs/tools-mcp-skills); Ratel's own coding-agent skills (including `ratel-decompose-prompt`, which extracts skills from a long system prompt) are on the [Skillset](https://www.ratel.sh/docs/skillset). ## Register a skill ```python import asyncio from ratel_ai import Skill, SkillCatalog, get_skill_content_tool, search_capabilities_tool skills = SkillCatalog() asyncio.run(skills.register( Skill( id="vercel-deploy", name="vercel-deploy", description="How to deploy to Vercel: env vars, preview vs production, rollbacks.", tags=["deploy", "ship to production"], # indexed for ranking tools=["vercel__deploy", "fs__read_file"], # surfaced alongside the skill when it matches metadata={"stacks": ["next", "vercel"]}, # non-indexed context for higher-layer ranking body="## Deploying to Vercel\n1. ...", # returned by get_skill_content_tool ) )) search = search_capabilities_tool(catalog, skills) # 2nd arg → result gains a populated `skills` bucket load = get_skill_content_tool(skills) # id == "get_skill_content" ``` Only `id`, `name`, and `description` are required; `tags`, `tools`, `metadata`, and `body` are optional. `get_skill_content({skillId})` returns `{"body": ...}`, or `{"error": ..., "isError": True}` for an unknown id. ## How skills surface Pass a `SkillCatalog` as the second argument to `search_capabilities_tool` and search returns the `skills` bucket alongside `tools`, each with its own result budget, so a relevant skill is never starved by matching tools. The agent pulls a skill's full body into context on demand via `get_skill_content_tool` (id `get_skill_content`, constant `GET_SKILL_CONTENT_ID`). A skill can also declare the `tools` its instructions call: when the skill matches a query, those tools are pulled into the `tools` bucket (additively, deduped) so the agent gets the playbook *and* the tools it needs in one turn instead of a second search. ## The `SkillCatalog` surface `SkillCatalog(trace=None, method="bm25", embedding=None)` takes the same constructor keywords as `ToolCatalog`, and its method surface mirrors it over the skill corpus: ```python skills.search(query, top_k) # → list[SkillHit] (.skill_id, .score, .rank, .fused); synchronous, BM25-only skills.search_async(query, top_k) # awaitable; any method; origin=, method= as on ToolCatalog skills.has(skill_id) # → bool skills.get(skill_id) # → Skill | None skills.size() # → int skills.invoke(skill_id) # → str: the body; raises ValueError on an unknown id skills.replace_all(iterable) # → PendingReplace: the batch becomes the whole catalog ``` Order hits by `.rank` (0-based, stable across methods) and branch on `.fused`: `True` means `.score` is an RRF value (hybrid search, or the experimental adaptive usage arm), not a raw BM25 or cosine score. `record_event()` and `drain_trace_events()` work exactly as on `ToolCatalog`. Note that `invoke` is synchronous here, no `await`: loading a skill body is a lookup, not an executor call. It records a `skill_invoke` trace event. ## Replace the whole catalog `replace_all` makes the batch the entire catalog ([ADR 0015](https://github.com/ratel-ai/ratel/blob/main/docs/adr/0015-whole-catalog-skill-reload.md)): ids absent from it are removed, including skills registered in-process. It exists for a source that fetches the full catalog rather than pushing deltas; a host that keeps local skills alongside a remote source composes the batch itself: ```python reload = skills.replace_all([*local_skills, *remote_skills]) reload.added, reload.removed, reload.updated, reload.unchanged # final before awaiting await reload # drives the embedding pass — always await ``` Two-phase, exactly like `register`: the corpus swap lands synchronously, and the returned `PendingReplace` already carries the final counts — awaiting drives only the embedding pass and resolves to a plain `ReplaceOutcome`. **Always `await` the result** so an embedding failure raises instead of being swallowed. On a failed pass the new corpus is already live and BM25 ranks it; semantic search raises embeddings-not-built until a later pass succeeds. A reload racing an in-flight operation — dense work, but also a plain `search_async` — is rejected with `RuntimeError: registry busy` rather than blended; retry it. Only new or re-worded skills are embedded, so reloading an unchanged catalog costs zero embedding calls. `PendingReplace` and `ReplaceOutcome` are exported from `ratel_ai`. The tool corpus has no equivalent: `register` stays the only way into a `ToolCatalog`. ## Embeddings for skills The skill corpus has its own embedding cache, maintained exactly like the tool one ([The embedding cache](https://www.ratel.sh/docs/python/register-tools#the-embedding-cache)): construct `SkillCatalog` with `method="semantic"` or `"hybrid"` so `await skills.register(...)` embeds each skill; a BM25 catalog cannot be retrofitted. The two caches are independent: skills can rank hybrid while tools stay on BM25, or run a different [embedding model](https://www.ratel.sh/docs/embedding-models). Embeddings cover the same projection the ranking indexes (name, description, tags), never the body, so a long playbook costs one small embedding. When the engines are worth it is on [Semantic & hybrid search](https://www.ratel.sh/docs/retrieval-and-search#semantic-and-hybrid-search). ## Next steps [Use the discovery tools](https://www.ratel.sh/docs/python/discovery-tools) Hand the agent search\_capabilities, invoke\_tool, and get\_skill\_content. [Tools vs MCP vs skills](https://www.ratel.sh/docs/tools-mcp-skills) When a capability should be a tool, an MCP server, or a skill. [Skillset](https://www.ratel.sh/docs/skillset) Coding-agent skills that extract, tune, and maintain the skills your catalog serves. --- # Use the discovery tools Source: https://www.ratel.sh/docs/python/discovery-tools The capability tools wrap a catalog into tools the agent can call itself: 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](https://www.ratel.sh/docs/progressive-disclosure-tools). ```python search = search_capabilities_tool(catalog) # id == "search_capabilities" invoke = invoke_tool_tool(catalog) # id == "invoke_tool" ``` `search_capabilities_tool` and `invoke_tool_tool` return plain `ExecutableTool` objects (`id`, `name`, `description`, `input_schema`, `output_schema`, `execute`). Wrap each one in your framework's tool type and run your normal loop. ## `search_capabilities` **`search_capabilities({query, topKTools?, topKSkills?})`** returns two independently-ranked buckets, so a relevant skill is never crowded out by matching tools (result keys are camelCase, a wire contract shared with the TypeScript SDK and MCP): ```jsonc { "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": "..."}] } ``` `topKTools` defaults to 5 and `topKSkills` to 3: integers above 50 clamp down to 50; a missing, `0`, negative, `bool`, or non-integer value falls back to the default rather than erroring. The `skills` bucket is always present and stays empty until you pass a `SkillCatalog` (see [Register skills](https://www.ratel.sh/docs/python/register-skills)). The keyword-only `upstream_servers=` advertises upstream MCP servers in the tool's description (see [Register MCP servers](https://www.ratel.sh/docs/python/register-mcps)). > **Upgrading from 0.1.x?** > > `search_tools_tool` (id `search_tools`, constant `SEARCH_TOOLS_ID`) is still exported as a deprecated, tools-only shim that keeps its original `{"groups": ...}` result shape. Migrate to `search_capabilities_tool`. ## `invoke_tool` **`invoke_tool({toolId, args})`** runs `catalog.invoke(tool_id, args)` and returns the tool's result. Arguments go *nested* under `args`, but a flattened call still works: when `args` is missing or `None`, every top-level key except `toolId` and `args` is forwarded to the tool. On a bad call it returns a structured `{"error": ..., "isError": True}` instead of raising, so a model mistake (unknown id, malformed args, a handler that throws) stays recoverable inside the loop rather than crashing the host. Expired upstream credentials get the same treatment. When an executor raises an exception whose type is named `UnauthorizedError` (duck-typed, so any library's class qualifies), `invoke_tool` returns `{"error": "needs_auth", "isError": True}` plus a re-auth `hint`; for namespaced `__` ids the hint names the upstream and an `upstream` key is added. Pass `on_unauthorized=` to `invoke_tool_tool` (an `OnUnauthorized` callback, sync or async, called with the upstream name) to trigger your re-auth flow at that moment; it fires only when an upstream is inferable. ## Skill content With a `SkillCatalog` registered, a third capability tool joins the set. It returns a skill's full body on demand: ```python search = search_capabilities_tool(catalog, skills) # 2nd arg → result gains a populated `skills` bucket load = get_skill_content_tool(skills) # id == "get_skill_content" ``` ## Assemble the per-turn toolset The capability tools are the escape hatch; the pre-filter covers the common case. Each turn, put the two capability tools in the list unconditionally, then add the top-K catalog hits for the user's message. The full catalog never enters the prompt: ```python def tools_for_turn(user_message: str) -> list[ExecutableTool]: capabilities = [search_capabilities_tool(catalog), invoke_tool_tool(catalog)] top_k = [ executable for hit in catalog.search(user_message, 3) # BM25: the 3 most relevant tools if (executable := catalog.get_executable(hit.tool_id)) is not None ] return [*capabilities, *top_k] ``` There are two dispatch paths, and getting them right matters because a registered executor may be sync or async: * **Catalog tools** (your top-K hits): dispatch through `await catalog.invoke(tool_id, args)`. It awaits the handler's coroutine only when needed and records the `invoke_*` trace events. Never `await` a raw `execute` yourself, or a sync handler raises. * **The two capability tools** (`search_capabilities` / `invoke_tool`): they are *not* catalog entries, and they own `async` handlers, so await `t.execute(args)` directly. The full Pydantic AI wiring is on [Framework integrations](https://www.ratel.sh/docs/python/framework-integrations). ## 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](https://www.ratel.sh/docs/recall-modes). ```python from pydantic_ai.messages import ModelRequest, ModelResponse, ToolCallPart, ToolReturnPart search = search_capabilities_tool(catalog, skills) # Before each model call, when the last message is the user's turn: async def append_recall(history: list, user_message: str, turn: int) -> None: call_id = f"recall_{turn}" # any id unique within the transcript result = await search.execute({"query": user_message}) history += [ ModelResponse(parts=[ToolCallPart(tool_name="search_capabilities", args={"query": user_message}, tool_call_id=call_id)]), ModelRequest(parts=[ToolReturnPart(tool_name="search_capabilities", content=result, tool_call_id=call_id)]), ] ``` Pydantic AI message shape shown; any framework's tool-call message shape works. The capability tools stay in the list, so the model still searches on demand for anything the recall missed. ## Retrieval methods Ranking is BM25 unless you opt out. Both catalogs take a `method=` constructor keyword. The synchronous `search` is BM25-only; `search_async` accepts a per-call method (`"bm25"`, `"semantic"`, or `"hybrid"`) that overrides the catalog default: ```python async def main(): catalog = ToolCatalog(method="hybrid") # catalog-wide default await catalog.register(tools) # embeds on a semantic/hybrid catalog await catalog.search_async("resize the logo", 5) # ranks with "hybrid" await catalog.search_async("resize the logo", 5, method="bm25") # per-call override asyncio.run(main()) ``` Semantic and hybrid rank against a prebuilt embedding cache. A catalog constructed as `"semantic"` or `"hybrid"` embeds each tool as it is registered, so a search only ever embeds the query; you cannot retrofit a BM25 catalog. The end-to-end workflow, including the typed failure modes, is on [Semantic & hybrid search](https://www.ratel.sh/docs/python/semantic-search). A search with no embeddings raises a typed `EmbedderError`; an unknown method string raises `ValueError`. `search_capabilities` passes no method, so it always inherits the catalog's construction-time default: switching the agent's engine means constructing the catalog with it. How BM25 ranks is covered in [Keyword search](https://www.ratel.sh/docs/retrieval-and-search#keyword-search); how the semantic and hybrid engines rank, fuse, and fail is covered in [Semantic & hybrid search](https://www.ratel.sh/docs/retrieval-and-search#semantic-and-hybrid-search). ## Next steps [Framework integrations](https://www.ratel.sh/docs/python/framework-integrations) Wire the capability tools and the pre-filter into Pydantic AI. [Progressive disclosure tools](https://www.ratel.sh/docs/progressive-disclosure-tools) The full contract these tools expose to the model, including the error table. [Semantic & hybrid search](https://www.ratel.sh/docs/retrieval-and-search#semantic-and-hybrid-search) When to switch methods, and what the embedding path costs. --- # Semantic & hybrid search Source: https://www.ratel.sh/docs/python/semantic-search BM25 is the default and needs none of this page. When paraphrase queries keep missing, opt into the dense engines: fix the method at construction, `await register(...)` to embed, search with `search_async`. How the engines rank is on [Retrieval and search](https://www.ratel.sh/docs/retrieval-and-search#semantic-and-hybrid-search); the model and its sources are on [Embedding models](https://www.ratel.sh/docs/embedding-models). ## Opt in and embed The method is fixed at construction, and `register` is the only thing that embeds: ```python import asyncio from ratel_ai import ToolCatalog catalog = ToolCatalog( method="hybrid", # every search defaults to hybrid # embedding={"huggingface": "..."}, # optional non-default model ) async def main(): # One batch, one embedding request. Metadata is stored synchronously, the # instant register() is called; the awaitable drives only the embedding # pass, on a worker thread that never blocks the event loop. await catalog.register(tools) asyncio.run(main()) ``` * Always await the result. A forgotten `await` never drops metadata, but it skips the embedding pass, and the next dense search raises with exactly that hint instead of ranking an empty cache. * Pass the whole batch at once: separate `register` calls embed separately. * Re-registering an id replaces it in place and costs one embedding, not N. * A `"bm25"` catalog never loads a model; its awaitable is a no-op. * A model or dimension change is not recovered in place: construct a new catalog and re-register. The cache lifecycle is on [The embedding cache](https://www.ratel.sh/docs/python/register-tools#the-embedding-cache). ## Configure another model Omit `embedding` and the default `BAAI/bge-small-en-v1.5` runs in-process, keyless. To use another model, name the source by its key — one of four: ```python # HuggingFace repo — in-process via Candle, like the default. ToolCatalog(method="hybrid", embedding={"huggingface": "BAAI/bge-base-en-v1.5"}) # Local checkpoint — same in-process engine, air-gapped. A bare path also works. ToolCatalog(method="hybrid", embedding={"local": "/opt/models/bge"}) # Ollama — the local server at http://localhost:11434. ToolCatalog(method="hybrid", embedding={"ollama": "nomic-embed-text"}) # OpenAI-compatible endpoint — the route for non-BERT or multilingual models. ToolCatalog( method="hybrid", embedding={ "url": "https://api.openai.com/v1/embeddings", "model": "text-embedding-3-small", "api_key_env": "OPENAI_API_KEY", # named env var, read at call time }, ) ``` `SkillCatalog` takes the same option. Which model each source loads, downloads, and how it fails is on [Embedding models](https://www.ratel.sh/docs/embedding-models). ## Search any method `search_async(query, top_k, origin="direct", method=None)` runs any method on a worker thread; `method=` overrides the catalog default for that call: ```python async def main(): hits = await catalog.search_async("rotate the api key", 5) bm25 = await catalog.search_async("rotate the api key", 5, method="bm25") ``` Synchronous `search` stays BM25-only: a `"semantic"`/`"hybrid"` method, passed or inherited from the catalog default, raises immediately with guidance to use `search_async`; an unknown method string raises `ValueError`. A search never embeds the corpus; on an unembedded corpus a dense search fails fast and the guard loads no model. `search_capabilities` always ranks with the construction-time default ([Use the discovery tools](https://www.ratel.sh/docs/python/discovery-tools)). ## Handle embedding failures Every dense failure from `await register(...)` or `await search_async(...)` is a typed `EmbedderError`. Branch on the class: ```python from ratel_ai import DimensionMismatchError, EmbedderError async def main(): try: await catalog.register(tools) except DimensionMismatchError: # the model changed under an existing corpus: new catalog + re-register raise except EmbedderError as err: print(err) # load / download / endpoint / auth detail is in the message ``` * `EmbedderError` subclasses `RuntimeError`, so existing `except RuntimeError` handlers keep working; `DimensionMismatchError` subclasses `EmbedderError`. * Unlike TypeScript there is no `.code` attribute: branch on the class and read the detail from the message. * Invalid embedding *config* is not an `EmbedderError`: it raises `ValueError` at construction, before any model loads. ## Skills rank the same way `SkillCatalog` mirrors all of it: `method=` and `embedding=` at construction, `await skills.register(...)` embeds, `skills.search_async(query, top_k, origin="direct", method=None)` ranks. The tool and skill caches are independent ([Register skills](https://www.ratel.sh/docs/python/register-skills#embeddings-for-skills)). ## Next steps [Embedding models](https://www.ratel.sh/docs/embedding-models) Swap the default model for another HuggingFace repo, a local checkpoint, Ollama, or an endpoint. [Retrieval and search](https://www.ratel.sh/docs/retrieval-and-search#keyword-vs-semantic-vs-hybrid) When each engine is worth it, and why better definitions come first. [Use the discovery tools](https://www.ratel.sh/docs/python/discovery-tools) How the capability tools inherit the catalog's retrieval method. --- # Framework integrations Source: https://www.ratel.sh/docs/python/framework-integrations The Python SDK ships no framework adapter packages — and needs none. That includes LangChain, LangGraph, LlamaIndex, and the OpenAI Agents SDK: each wires up with the same two-piece recipe below. Everything the SDK hands you, your registered tools and the capability tools (`search_capabilities` / `invoke_tool`; `get_skill_content` joins the set when a `SkillCatalog` is registered), is a plain executable tool: metadata plus an `execute` handler. Wiring a framework is two small pieces: 1. **An adapter** from that shape to the framework's tool type. 2. **A turn assembler** that builds each turn's tool list: the always-present capability tools plus the top-K catalog hits for the user's message ([ADR 0004](https://github.com/ratel-ai/ratel/blob/main/docs/adr/0004-retrieval-and-tool-selection.md) replace-mode pre-filtering). This page follows the shipped [`examples/pydantic-ai`](https://github.com/ratel-ai/ratel/blob/main/examples/pydantic-ai/README.md). Using the Vercel AI SDK? Its wiring lives on the [TypeScript framework integrations](https://www.ratel.sh/docs/typescript/framework-integrations) page. ## Pydantic AI The example pins `pydantic-ai>=1.0` alongside `ratel-ai`, on Python 3.10+. Every tool is built from the catalog's JSON schemas via Pydantic AI's `Tool.from_schema`, no Python signature introspection, so the schema the model sees is the one Ratel ranks. ### The adapter: two dispatch paths Catalog tools (the top-K hits) dispatch through `catalog.invoke`; the capability tools own `async def` handlers and are awaited directly: ```python from typing import Any from pydantic_ai import Tool from ratel_ai import ToolCatalog def _tool_from_fn(fn: Any, name: str, description: str, schema: dict[str, Any]) -> Tool: fn.__name__ = name return Tool.from_schema(fn, name=name, description=description, json_schema=schema) def _catalog_tool(catalog: ToolCatalog, tool_id: str, description: str, schema: dict[str, Any]) -> Tool: # `invoke` is the SDK's tested entry point: it handles sync *and* async # executors and emits the `invoke_*` trace events. async def fn(**kwargs: Any) -> Any: return await catalog.invoke(tool_id, kwargs) return _tool_from_fn(fn, tool_id, description, schema) def _capability_tool(execute: Any, name: str, description: str, schema: dict[str, Any]) -> Tool: # search_capabilities / invoke_tool are not catalog entries; their execute # handlers are async, so await them directly. async def fn(**kwargs: Any) -> Any: return await execute(kwargs) return _tool_from_fn(fn, name, description, schema) ``` > Never `await` a catalog tool's raw `execute` in Python: executors may be sync, and re-wrapping them is how a sync executor ends up wrongly awaited (`TypeError: object dict can't be used in 'await' expression`). Route catalog tools through `catalog.invoke`, which awaits only when needed. ### Assemble the turn and run the agent ```python from pydantic_ai import Agent from ratel_ai import invoke_tool_tool, search_capabilities_tool def build_tools(catalog: ToolCatalog, prompt: str, initial_top_k: int = 3) -> list[Tool]: search = search_capabilities_tool(catalog) invoke = invoke_tool_tool(catalog) tools: dict[str, Tool] = { search.id: _capability_tool(search.execute, search.id, search.description, search.input_schema), invoke.id: _capability_tool(invoke.execute, invoke.id, invoke.description, invoke.input_schema), } for hit in catalog.search(prompt, initial_top_k): executable = catalog.get_executable(hit.tool_id) if executable is not None: tools[executable.id] = _catalog_tool( catalog, executable.id, executable.description, executable.input_schema ) return list(tools.values()) async def run_agent(*, prompt: str, model: str, catalog: ToolCatalog) -> str: agent = Agent(model, tools=build_tools(catalog, prompt)) result = await agent.run(prompt) return result.output ``` Pydantic AI runs its own loop: `await agent.run(prompt)` auto-executes tools and threads results back until the final answer. `model` is a provider-prefixed id string rather than a model object, and the example sets no explicit step cap. ### Run it ```python import asyncio import os from agent import run_agent from tools import build_catalog # your ToolCatalog assembly model = os.environ.get("RATEL_EXAMPLE_MODEL", "openai:gpt-5-mini") text = asyncio.run(run_agent(prompt="find every TODO comment under src/", model=model, catalog=build_catalog())) ``` To run the shipped example from a checkout (`uv run` builds the native extension on first run): ```bash export OPENAI_API_KEY=sk-... # or ANTHROPIC_API_KEY, GEMINI_API_KEY, GROQ_API_KEY uv run main.py "send an email to alice@example.com saying ship it" ``` Swapping providers is an env var, not an import: `RATEL_EXAMPLE_MODEL=anthropic:claude-sonnet-4-6`. With no supported API key set, the example prints the BM25 top-3 in diagnostic mode and never calls a model. ## The same pattern over an MCP server [`examples/mcp-chat`](https://github.com/ratel-ai/ratel/blob/main/examples/mcp-chat/README.md) applies the identical wiring to tools it does not own: it ingests an upstream MCP server's tools into the catalog (server-namespaced ids like `ev__echo`), then rebuilds the tool list every turn. With 13 upstream tools registered and a top-K of 3, the model sees five tool definitions per turn; the rest stay reachable through `search_capabilities` / `invoke_tool` without occupying context. In Python, ingestion is [`register_mcp_server`](https://www.ratel.sh/docs/python/register-mcps). ## Any other framework LangChain, OpenAI Agents, a hand-rolled loop: the recipe does not change, because `ToolCatalog` entries and the capability-tool factories all return the same plain executable shape (`id`, `name`, `description`, input/output schemas, `execute`). Write one adapter from that shape to your framework's tool type, and one assembler that puts the capability tools plus the top-K hits into each turn. [Progressive disclosure tools](https://www.ratel.sh/docs/progressive-disclosure-tools) covers the contract those tools expose to the model. ## Next steps [Register tools](https://www.ratel.sh/docs/python/register-tools) Build the ToolCatalog these examples pre-filter: local functions and upstream MCP tools. [Telemetry & OTel](https://www.ratel.sh/docs/python/telemetry) See the invoke and gateway events these integrations emit, locally and over OpenTelemetry. --- # Telemetry & OTel Source: https://www.ratel.sh/docs/python/telemetry Ratel emits telemetry on two deliberately separate streams ([ADR 0007](https://github.com/ratel-ai/ratel/blob/main/docs/adr/0007-telemetry-two-streams.md)): **local trace events** recorded into a sink you configure, and **OpenTelemetry signals** exported as stock OTLP — funnel spans, plus gated content on Logs EventRecords. Both are off by default. This page is the Python wiring. The full event-type vocabulary and span table live on [Telemetry](https://www.ratel.sh/docs/observability). ## Local trace sinks Pass `trace` to the `ToolCatalog` (or `SkillCatalog`) constructor and the core records every search, invoke, capability-tool, upstream-MCP, and auth event into the sink: ```python from ratel_ai import ToolCatalog, TraceSinkConfig catalog = ToolCatalog( trace=TraceSinkConfig(kind="jsonl", session_id="session-1", path="/tmp/ratel.jsonl"), ) # Every catalog.invoke, capability-tool call, and register_mcp_server call now # appends one JSON line per event to /tmp/ratel.jsonl. ``` `TraceSinkConfig` is a dataclass: `kind` is `"noop" | "memory" | "jsonl"`; `session_id` is required for memory and jsonl, `path` for jsonl. Misconfiguration raises `ValueError` (for example `"jsonl sink requires path"`). Sink kinds: * `kind="noop"`: drop everything (the default when no `trace` is passed). * `kind="memory"`: keep events in memory; drain via `catalog.drain_trace_events()`, which returns the buffered events and removes them. Useful in tests. * `kind="jsonl"`: append one JSON line per event to `path` (mode `0600` on Unix). Best-effort, lossy on backpressure. Inject your own events with `catalog.record_event(event)`: the dict must deserialize to a core trace event, else the native layer raises `ValueError` (`"invalid trace event: ..."`). `search_capabilities_tool` records its own `gateway_search` event and tags the underlying catalog search's `search` event with `origin="agent"`; direct callers (`catalog.search(query, k)`) default to `"direct"`. Override per call via `catalog.search(query, k, "agent")`. ## OpenTelemetry export Independently of the local sink, the SDK emits OpenTelemetry spans for the same funnel: `execute_tool`, `ratel.search`, `ratel.skill.load`, `ratel.upstream.register`, `ratel.auth.flow` (the `gen_ai.*` / `ratel.*` vocabulary, catalogued in [Telemetry](https://www.ratel.sh/docs/observability)). With [content capture](#content-capture) in an event mode, it also emits structured OpenTelemetry Logs EventRecords. The base `ratel-ai` install carries one dependency: `ratel-ai-telemetry>=0.1.3`, the OTel-free `ratel.*` vocabulary and EventRecord contract. There is still no OpenTelemetry in the base install — without it, every telemetry helper is a straight pass-through: zero overhead, no spans. The `[otlp]` extra pulls `ratel-ai-telemetry[otlp]` (the OpenTelemetry SDK plus OTLP exporters); with OpenTelemetry present, telemetry goes to whatever providers are registered. ### Greenfield: ship spans to Ratel Cloud No OpenTelemetry in your stack yet? One call at startup registers matched global tracer **and** logger providers, with batch OTLP trace and Logs exporters pointed at Ratel Cloud. The exporters ship behind the `[otlp]` extra (without it, `configure_telemetry` raises `ModuleNotFoundError` with this install hint): ```bash pip install 'ratel-ai[otlp]' ``` ```python import os from ratel_ai import configure_telemetry handle = configure_telemetry( endpoint=os.environ.get("RATEL_OTLP_ENDPOINT", "https://cloud.ratel.sh/v1/traces"), api_key=os.environ["RATEL_API_KEY"], ) # ... run your agent: every ratel.* / execute_tool span (and gated content # EventRecord) now exports ... handle.shutdown() # flush both exporters on exit (handle.force_flush() mid-run) ``` The full keyword surface is `configure_telemetry(*, api_key=None, endpoint=None, logs_endpoint=None, headers=None, service_name=None, capture_content=None, include_span_and_events=None, export_all_spans=False)`: `capture_content` / `include_span_and_events` set the [content-capture](#content-capture) gate in code. It returns a per-call shutdown handle (`handle.shutdown()` / `handle.force_flush()`) over the shared Ratel-owned providers; shutting any handle down stops export for every caller. Resolution rules: * An explicit `endpoint=` beats `RATEL_OTLP_ENDPOINT`, which beats the superseded `RATEL_URL` (still works, raises a `DeprecationWarning`; the fallback is slated for removal in the next minor — `RATEL_URL` also names the SDK's catalog source). Either way it is the **full OTLP traces URL including `/v1/traces`**; with none set it raises `ValueError` naming `RATEL_OTLP_ENDPOINT`. * `logs_endpoint` defaults to the sibling Logs URL: the traces URL with `/v1/traces` swapped for `/v1/logs`. * `api_key` is sent as `Authorization: Bearer ` on top of any `headers`. When omitted it falls back to the `RATEL_API_KEY` env var (unless you already set an `Authorization` header explicitly). * `service_name` defaults to `"ratel"`. * `export_all_spans=False` (the default) exports only the `gen_ai.*` / `ratel.*` signal; pass `True` to forward every span. > `configure_telemetry` owns the global tracer and logger providers, and `shutdown()` flushes both. Calling it again once Ratel's providers are registered is idempotent: you get a handle to the same shared providers. It raises `RuntimeError` in two cases: a foreign `TracerProvider` **or** `LoggerProvider` is already registered (you are on the wrong path — dual-export instead, with both processors), or telemetry was already shut down in this process (shutdown is terminal). ### Already on OpenTelemetry: dual-export If Langfuse or your own collector already owns the providers, the SDK's telemetry is already flowing to them. Skip `configure_telemetry` and add **both** processors from [`ratel-ai-telemetry`](https://www.ratel.sh/docs/packages/python/ratel-ai-telemetry) to send a copy of the Ratel cut to Cloud: `ratel_span_processor` on the `TracerProvider` for spans, `ratel_log_record_processor` on the `LoggerProvider` for the content EventRecords. Without the log-record processor, captured content never reaches Cloud. ```python import os from opentelemetry import _logs, trace from opentelemetry.sdk._logs import LoggerProvider from opentelemetry.sdk.trace import TracerProvider from ratel_ai_telemetry.otlp import ratel_log_record_processor, ratel_span_processor endpoint = os.environ.get("RATEL_OTLP_ENDPOINT", "https://cloud.ratel.sh/v1/traces") api_key = os.environ["RATEL_API_KEY"] tracer_provider = trace.get_tracer_provider() # the provider your existing setup registered if not isinstance(tracer_provider, TracerProvider): raise RuntimeError("register your OpenTelemetry SDK provider before adding processors") tracer_provider.add_span_processor(ratel_span_processor(endpoint=endpoint, api_key=api_key)) logger_provider = _logs.get_logger_provider() # likewise for the Logs half if not isinstance(logger_provider, LoggerProvider): raise RuntimeError("register an OpenTelemetry LoggerProvider before adding processors") logger_provider.add_log_record_processor( ratel_log_record_processor(endpoint=endpoint, api_key=api_key) ) ``` `ratel_span_processor(*, api_key=None, endpoint=None, headers=None, span_filter=None, enabled=True)` (pass `enabled=False` for a no-op processor); `ratel_span_exporter(...)` is the bare OTLP exporter if you wire your own processor. The default filter forwards a span if its name starts with `ratel.` or any attribute key starts with `gen_ai.` or `ratel.`; pass `span_filter=lambda _s: True` to forward everything. `ratel_log_record_processor(*, api_key=None, endpoint=None, logs_endpoint=None, headers=None, log_filter=None, enabled=True)` is the Logs twin: it batches to `ratel_log_exporter(...)`, and `logs_endpoint` defaults to the sibling `/v1/logs` URL. The default `ratel_event_filter` forwards EventRecords named `gen_ai.*` / `ratel.*`. ### Content capture Message and tool content never leaves the process by default. When the gate opens, content rides two independent channels: * **Span attributes** (`SPAN_ONLY` / `SPAN_AND_EVENT`): `ratel.search.query`, `gen_ai.tool.call.arguments` / `gen_ai.tool.call.result` on the funnel spans. * **EventRecords** (`EVENT_ONLY` / `SPAN_AND_EVENT`): structured OpenTelemetry **Logs** records — `ratel.tool.execution.details` (tool arguments, plus the result on success) and `ratel.search.results` (the search query). This is the Logs data model, not span events, so it needs a Logs pipeline: the greenfield path wires one; dual-export needs `ratel_log_record_processor`. `gen_ai.output.messages` stays reserved for model outputs with a `finish_reason`; tool results are never encoded there. Set the gate in code: * Greenfield path: `configure_telemetry(capture_content=...)`. * Dual-export path: `set_content_capture` from `ratel_ai_telemetry`. Code wins over the `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` env var, the fallback when nothing is set in code. The full mode table is on [Telemetry](https://www.ratel.sh/docs/observability). ## Next steps [Telemetry](https://www.ratel.sh/docs/observability) The full event-type vocabulary and OpenTelemetry span table, language-neutral. [ratel-ai-telemetry](https://www.ratel.sh/docs/packages/python/ratel-ai-telemetry) The raw API reference and changelog for the telemetry package. [Packages](https://www.ratel.sh/docs/python/packages) The Python packages and where their generated references live. --- # Packages Source: https://www.ratel.sh/docs/python/packages Two packages make up the Python surface: | Package | What it is | | ------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | [`ratel-ai`](https://www.ratel.sh/docs/packages/python/ratel-ai) | The SDK (`import ratel_ai`): `ToolCatalog`, `SkillCatalog`, the capability tools, and MCP ingestion, over a PyO3 binding to the Rust core. | | [`ratel-ai-telemetry`](https://www.ratel.sh/docs/packages/python/ratel-ai-telemetry) | The `gen_ai.*` / `ratel.*` span vocabulary plus the OTLP export helpers (`ratel_span_processor`, `ratel_span_exporter`); pulled in by the `[otlp]` extra. | Each package page carries the raw generated API reference and its changelog. For the cross-language package map, see [Packages](https://www.ratel.sh/docs/packages). --- # Package list Source: https://www.ratel.sh/docs/packages The Ratel SDK ships as **eight packages across three languages**: two SDKs, two framework adapters, a Rust core, and a telemetry stack. Ratel Local ships separately as `@ratel-ai/ratel-local`. The Core package pages below expose every exported symbol with its signature and doc comment, generated straight from [ratel-ai/ratel](https://github.com/ratel-ai/ratel), plus each package's changelog, verbatim. For the guided, cross-language SDK surface, start at [SDKs](https://www.ratel.sh/docs/sdks) instead. | Package | Language | What it is | | ------------------------------------------------------------------------------------------ | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [`ratel-ai-core`](https://www.ratel.sh/docs/packages/rust/ratel-ai-core) | Rust | The Rust core both SDKs bundle: registries, retrieval, trace stream | | [`ratel-ai-telemetry` (crate)](https://www.ratel.sh/docs/packages/rust/ratel-ai-telemetry) | Rust | The Rust vocabulary for the `ratel.*` semantic conventions | | [`@ratel-ai/sdk`](https://www.ratel.sh/docs/packages/typescript/sdk) | TypeScript | The TypeScript SDK: the `ratel()` core, catalogs, capability tools, MCP ingestion | | [`@ratel-ai/vercel-ai-sdk`](https://www.ratel.sh/docs/packages/typescript/vercel-ai-sdk) | TypeScript | Vercel AI SDK adapter: `ratel().adaptTo(aiSdk())` — AI SDK-native tools and per-turn recall (`ai@5`–`7`), plus the `./otel` telemetry integration (`RatelOtelIntegration`, `ai@7`) | | [`@ratel-ai/mastra`](https://www.ratel.sh/docs/packages/typescript/mastra) | TypeScript | Mastra adapter: `ratel().adaptTo(mastra())` — `createTool` tools and per-turn recall via `recallProcessor()` (`@mastra/core` 1.11+) | | [`@ratel-ai/telemetry`](https://www.ratel.sh/docs/packages/typescript/telemetry) | TypeScript | OTel-free `ratel.*` semantic-convention vocabulary | | [`@ratel-ai/ratel-local`](https://www.ratel.sh/docs/packages/typescript/mcp-server) | TypeScript | The package that powers Ratel Local: MCP serving, CLI configuration, and library embedding | | [`ratel-ai`](https://www.ratel.sh/docs/packages/python/ratel-ai) | Python | The Python SDK: same surface as TypeScript, PyO3-bundled core | | [`ratel-ai-telemetry`](https://www.ratel.sh/docs/packages/python/ratel-ai-telemetry) | Python | The `ratel.*` overlay on OpenTelemetry `gen_ai` spans | Every generated Core package page carries its source commit. The generator lives in the docs repo (`apps/docs/scripts/sync-api-reference.mjs`) and pulls from doc comments (rustdoc, TSDoc, and Python docstrings), which are lint-enforced at the source, so these pages track the shipped API rather than a hand-maintained copy. The Ratel Local package reference points to its separately maintained source and product documentation. --- # ratel-ai-core Source: https://www.ratel.sh/docs/packages/rust/ratel-ai-core > v0.7.0 · Rust · [crates.io](https://crates.io/crates/ratel-ai-core) · [source](https://github.com/ratel-ai/ratel/tree/main/src/core) · [docs.rs](https://docs.rs/ratel-ai-core) > > Generated from the doc comments at `ratel-ai/ratel@d8635d5` — do not edit by hand. Regenerate with `pnpm sync:api` in `apps/docs`. Tool and skill retrieval for AI agents — the Rust core of the Ratel context engineering platform. Agents degrade when every tool definition is stuffed into the context window. This crate keeps the full catalog *outside* the context and retrieves only the entries relevant to the task at hand: register tools and skills once, then search them per turn. The engine runs in-process; BM25 and local dense retrieval need no server, while dense retrieval may instead use a configured OpenAI-compatible embedding endpoint. # Mental model Two registries hold the corpus, one per capability kind: * `ToolRegistry` indexes `Tool`s — callable endpoints described by a name, a description, and JSON schemas. * `SkillRegistry` indexes `Skill`s — reusable instruction playbooks whose body is dispatched on demand. Both rank a query with one of three engines, selected by `SearchMethod`: * `SearchMethod::Bm25` (default) — lexical BM25. Needs no model and never fails; `ToolRegistry::search` and `SkillRegistry::search` use it unconditionally. * `SearchMethod::Semantic` — cosine similarity over dense embeddings from a configurable in-process HuggingFace/local model (default `bge-small-en-v1.5`) or OpenAI-compatible endpoint (ADR-0011/ADR-0012). * `SearchMethod::Hybrid` — the BM25 and dense rankings fused with Reciprocal Rank Fusion. Semantic and hybrid searches rank against an embedding cache built by `ToolRegistry::build_embeddings` / `SkillRegistry::build_embeddings`; a search itself never embeds the corpus and never downloads the model. Every register and search also emits a `TraceEvent` on the registry's `TraceSink` — the local trace stream behind the inspector and usage reporting (ADR-0007). The default sink is `NoopSink` (discard); `MemorySink` buffers for tests and introspection, `JsonlSink` appends to a local file. # Example: register and search (BM25) ```rust use ratel_ai_core::{Tool, ToolRegistry}; let mut registry = ToolRegistry::new(); registry.register(Tool { id: "read_file".into(), name: "read_file".into(), description: "Read a file from disk".into(), input_schema: serde_json::json!({ "properties": { "path": { "type": "string", "description": "absolute path" } } }), output_schema: serde_json::json!({}), }); registry.register(Tool { id: "send_email".into(), name: "send_email".into(), description: "Send an email to a recipient".into(), input_schema: serde_json::json!({}), output_schema: serde_json::json!({}), }); let hits = registry.search("read a file", 5); assert_eq!(hits[0].tool_id, "read_file"); ``` The language SDKs (`@ratel-ai/sdk` on npm, `ratel-ai` on PyPI) bundle this crate and surface the same model; the agent-facing capability tools (`search_capabilities` / `invoke_tool` / `get_skill_content`) sit on top of them. Design rationale lives in the repo's `docs/adr/`. ## Full item-level reference rustdoc renders every public item of this crate — with the same doc comments — on [docs.rs](https://docs.rs/ratel-ai-core). --- # Changelog Source: https://www.ratel.sh/docs/packages/rust/ratel-ai-core/changelog > ratel-ai-core release history · [crates.io](https://crates.io/crates/ratel-ai-core) · [source](https://github.com/ratel-ai/ratel/tree/main/src/core) · [docs.rs](https://docs.rs/ratel-ai-core) > > Synced verbatim from the package's `CHANGELOG.md` at `ratel-ai/ratel@d8635d5` — do not edit by hand. Regenerate with `pnpm sync:api` in `apps/docs`. All notable changes to `ratel-ai-core` are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this crate adheres to [Semantic Versioning](https://semver.org/). ## \[0.7.0] - 2026-08-07 ### Added * **Whole-corpus skill reload (ADR-0015).** `SkillRegistry::replace_all` makes the batch the *entire* skill corpus and diffs it against the live one, so an id removed upstream stops being searchable — until now the registry was append-only (`register` replaced an id in place, nothing removed one). The dense cache is touched only where it must be: removed ids' vectors are dropped, changed indexed text is invalidated, everything else is kept, so reloading an unchanged catalog costs zero embeddings. Returns the new public `ReplaceOutcome` (added / removed / updated / unchanged counts). It is the only source of `ChurnKind::Remove` for skills; `TraceEvent::SkillChurn` fires for real changes only. * `Skill` derives `PartialEq` / `Eq`. ### Changed * **The BM25 index is cached across searches and rebuilt only on catalog mutation.** Every search used to rebuild the whole BM25 engine (two full-corpus tokenization passes) and re-derive `searchable_text` for every item — roughly 100× the cost of the query itself (61 ms vs 0.6 ms at 1k tools). The first search after a mutation now builds the index through the same full-corpus path and later searches reuse it; `ToolRegistry::register`, `SkillRegistry::register`, and `SkillRegistry::replace_all` invalidate it (`replace_all` keeps it when no indexed text changed), and the hybrid arms share the same cached index instead of deriving `searchable_text` a second time. Scores are byte-for-byte unchanged (ADR-0011) — this is purely a latency win, no API change. ## \[0.6.0] - 2026-07-28 ### Added * **Adaptive usage ranking (ADR-0014).** A capability search followed by an invoke becomes a weighted edge in an in-memory `IntentGraph`; matched clusters then boost future rankings through a sub-unit RRF arm fused beside BM25/dense retrieval. Clusters carry support counts and are matched per-member lexically (Jaccard) or by centroid cosine on a semantic catalog, labelled by medoid + c-TF-IDF terms, and aged out by recency (a grace window then a half-life) with eviction and a per-cluster member cap. The arm never overrides a strong lexical/dense match — it lifts capabilities usage history supports. * `IntentGraph` value type with `protocol/v1` serialization: `to_json` / `from_json` (semantically validated on load, so a malformed or incompatible graph is rejected rather than silently degrading), a `rev` write-counter for save-when-changed persistence and stale-base detection, and `cluster_count`. * `rank` (0-based, scale-invariant position) and `fused` (whether the usage arm was mixed into this ranking) on `SearchHit`, so callers can order on `rank` and see when `score` switched from raw BM25/cosine to an RRF scale. * Embedding-model-change detection: a graph built under one model is detected against the active model (fingerprint / dimension) and its arm pauses rather than boost on stale centroids; `rebuild_embeddings` re-embeds cluster members under the current model, preserving support and edges. ### Changed * **BREAKING:** `TraceEvent` is now `#[non_exhaustive]`. Downstream `match`es over it must include a `_ =>` arm; in return, future event variants (such as the new `usage_boost`) are non-breaking. In-crate matches and the serde wire form are unaffected. ## \[0.5.0] - 2026-07-20 ### Added * Configurable dense retrieval via public `EmbeddingModel`, `EmbeddingSpec`, and `Pooling` types: built-in default, HuggingFace, local Candle directories, and OpenAI-compatible endpoints. * `ToolRegistry::rebuild_embeddings` and `SkillRegistry::rebuild_embeddings` atomically recompute the full dense corpus. Failed rebuilds preserve the prior complete cache. * `EmbedderError::ModelMismatch` rejects model-identity drift with guidance to rebuild. * Embedding download, pooling-assumption, and model-mismatch trace events. ### Changed * **BREAKING:** `EmbedderError` and `TraceEvent` add public variants for configurable-model validation and lifecycle failures; exhaustive matches must handle them. * Dense cache batches are validated and committed atomically. Endpoint embedding requests are chunked at 64 inputs, responses are capped at 64 MiB, optional response model identity is enforced, and malformed indices/vectors are rejected. * Endpoint client-cache identity includes the `api_key_env` name without including its secret value, preventing credential cross-talk while preserving vector-space identity. * Dense searches and rebuilds share an operation guard, preventing a rebuild from swapping vector spaces between query validation and ranking. Fingerprint fields are length-delimited to prevent configuration collisions. * Public `EmbeddingModel` values can be checked with `validate()` and are validated before a lazy model load; SDK `EmbeddingSpec` construction remains fail-fast. * In-process (Candle) embedding runs one padded forward pass per batch chunk instead of one per document, speeding up embedding a corpus on a `"semantic"`/`"hybrid"` catalog. Produced vectors are bit-for-bit identical, so rankings and reproducibility are unchanged. ### Fixed * Failed incremental embedding batches can no longer leave partial vectors, dimensions, or model fingerprints in the cache. * Distinct embedding models now load concurrently. The process-wide embedder cache held its lock across a full model load, so a cold load of one model blocked loading — or a warm-cache hit for — another; loads now run under a per-key slot lock while same-model loads stay single-flight (one load, reported once). ## \[0.4.0] - 2026-07-09 ### Fixed * Re-registering a tool or skill (MCP re-sync, hot-reload) left a stale duplicate in the corpus instead of replacing it in place, causing BM25 score drift and an unbounded memory leak. `ToolRegistry`/`SkillRegistry` are now id-keyed so `register` replaces in place, and the dense embedding cache invalidates on replace so `build_embeddings` re-embeds the changed id. ## \[0.3.0] - 2026-07-06 ### Added * **Selectable retrieval methods** (ADR-0011): a `SearchMethod` enum — `Bm25` (default), `Semantic`, `Hybrid` — chosen per registry or per call via `ToolRegistry::search_with_method` / `SkillRegistry::search_with_method`. Semantic ranks a local `BAAI/bge-small-en-v1.5` embedding (pure-Rust Candle); hybrid fuses the BM25 and dense arms with Reciprocal Rank Fusion (no reranker). * `EmbedderError` (surfaced from `search_with_method` on the semantic/hybrid path) and a `TraceEvent::EmbedderLoad` / `EmbedderLoadStatus` flagging a slow (possibly underpowered machine) or failed model load. * `ToolRegistry::build_embeddings` / `SkillRegistry::build_embeddings` — pre-compute embeddings for not-yet-embedded tools/skills so a later semantic/hybrid search only embeds the query. ### Changed * BM25 remains the default engine. `search` / `search_with_origin` keep their infallible `Vec` signature and BM25 behavior unchanged. * The dense embedding cache is now **incremental** — a growing prefix of the corpus. `register` only appends (never invalidates), and `build_embeddings` embeds only newly-registered tools, so an existing vector is never recomputed (adding one tool costs one embedding, not N). A BM25-only registry still never loads the model. * A semantic/hybrid search over an un-built corpus now returns `EmbedderError::EmbeddingsNotBuilt` instead of embedding inside the search path — a search never silently pays the corpus-embedding cost. Populate the cache with `build_embeddings()` first. ## \[0.2.1-rc.1] - 2026-07-04 ### Changed * First release cut under the per-package release scheme (ADR-0008): `ratel-ai-core` now versions and ships independently, tagged `core-v*`. No crate API changes since 0.2.0. ## \[0.2.0] - 2026-06-16 ### Added * First-class **skills**: a `Skill { id, name, description, tags, tools, metadata, body }` type and a separate `SkillRegistry` BM25 index — ranked independently of tools. Only `name`/`description`/`tags` are indexed; `tools` (a declared dependency edge surfaced at the gateway), `metadata` (non-indexed context such as `stacks`), and `body` are not. Plus `skill_search` / `skill_churn` / `skill_invoke` trace events for the retrieval funnel. ## \[0.1.6] - 2026-06-10 ### Changed * Version bump for the coordinated v0.1.6 release (first release shipping the `ratel-ai` Python SDK). No crate source changes since 0.1.5; re-published in lockstep to keep all artifacts version-aligned. ## \[0.1.5] - 2026-05-10 ### Added * Initial release on the v1 (revamp) line. BM25 tool retrieval, MCP ingestion, framework-neutral catalog. See the [crate README](https://github.com/ratel-ai/ratel/blob/main/src/core/README.md) for the full surface. * `trace` module: `TraceEvent` tagged enum, `TraceEnvelope`, `TraceSink` trait with `NoopSink`, `MemorySink`, and `JsonlSink` (synchronous `O_APPEND`, mode `0600` on Unix) — single tagged event stream per [ADR-0007](https://github.com/ratel-ai/ratel/blob/main/docs/adr/0007-telemetry-two-streams.md). `ToolRegistry::with_trace_sink` / `set_trace_sink` / `record_event` plus a `search_with_origin` method. `register` emits `index_churn{Add}`; `search` emits `search` with a `bm25` stage. The origin enum tags each search as `direct` (Rust callers, pre-fetch helpers, benchmarks) or `agent` (LLM-synthesized via the gateway), to let downstream consumers separate the two paths. --- # ratel-ai-telemetry (Rust) Source: https://www.ratel.sh/docs/packages/rust/ratel-ai-telemetry > v0.1.1 · Rust · [crates.io](https://crates.io/crates/ratel-ai-telemetry) · [source](https://github.com/ratel-ai/ratel/tree/main/src/telemetry/core) · [docs.rs](https://docs.rs/ratel-ai-telemetry) > > Generated from the doc comments at `ratel-ai/ratel@d8635d5` — do not edit by hand. Regenerate with `pnpm sync:api` in `apps/docs`. `ratel-ai-telemetry` — the `ratel.*` telemetry vocabulary. See [README.md](https://github.com/ratel-ai/ratel/blob/main/src/telemetry/core/README.md) and the wire contract in [CONVENTIONS.md](https://github.com/ratel-ai/ratel/blob/main/src/telemetry/CONVENTIONS.md) for design. ## Full item-level reference rustdoc renders every public item of this crate — with the same doc comments — on [docs.rs](https://docs.rs/ratel-ai-telemetry). --- # Changelog Source: https://www.ratel.sh/docs/packages/rust/ratel-ai-telemetry/changelog > ratel-ai-telemetry release history · [crates.io](https://crates.io/crates/ratel-ai-telemetry) · [source](https://github.com/ratel-ai/ratel/tree/main/src/telemetry/core) · [docs.rs](https://docs.rs/ratel-ai-telemetry) > > Synced verbatim from the package's `CHANGELOG.md` at `ratel-ai/ratel@d8635d5` — do not edit by hand. Regenerate with `pnpm sync:api` in `apps/docs`. All notable changes to `ratel-ai-telemetry` (the Rust telemetry constants crate) are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this package adheres to [Semantic Versioning](https://semver.org/). ## \[0.1.1] - 2026-07-26 ### Added * `GEN_AI_SYSTEM_INSTRUCTIONS`, `GEN_AI_INPUT_MESSAGES`, `GEN_AI_OUTPUT_MESSAGES`, and `RATEL_TOOL_EXECUTION_DETAILS` EventRecord constants. ### Changed * Clarify that content events use the OpenTelemetry Logs Event API and that inference output messages require `finish_reason`. * `RATEL_ORIGIN` is now specified for third-party `gen_ai.*` spans that a framework adapter overlays, not just Ratel's own search/invoke spans. The constant, its `direct | agent` values, and `SEMCONV_VERSION` are unchanged; only the shared vocabulary spec widened (`CONVENTIONS.md`). ## \[0.1.0] - 2026-07-06 ### Added * The telemetry vocabulary (ADR-0015): the full `ratel.*` constants (attribute keys, span/event names, `gen_ai.*` interop keys, and the `Origin`/`SearchTarget`/`AuthOutcome` value enums) pinned to OpenTelemetry semconv `gen_ai` v1.42.0, as zero-dependency `&str` constants and enums. * The `contract-against-the-pin` test suite: every constant is asserted against its pinned wire key, so the vocabulary cannot drift from `CONVENTIONS.md`. ### Changed * Released as an independent crates.io unit under the `telemetry-core-v*` tag prefix. This crate is constants-only; the TS and Python helpers ship `init()`. --- # @ratel-ai/sdk Source: https://www.ratel.sh/docs/packages/typescript/sdk > v0.7.0 · TypeScript · [npm](https://www.npmjs.com/package/@ratel-ai/sdk) · [source](https://github.com/ratel-ai/ratel/tree/main/src/sdk/ts) > > Generated from the doc comments at `ratel-ai/ratel@d8635d5` — do not edit by hand. Regenerate with `pnpm sync:api` in `apps/docs`. `@ratel-ai/sdk` — TypeScript SDK for Ratel, the context engineering platform for AI agents. In-process, no infra: a native (Rust) BM25/semantic/hybrid index behind [ToolCatalog](#api-toolcatalog) and [SkillCatalog](#api-skillcatalog), MCP ingestion via [registerMcpServer](#api-registermcpserver), and the framework-neutral capability tools ([searchCapabilitiesTool](#api-searchcapabilitiestool), [invokeToolTool](#api-invoketooltool), [getSkillContentTool](#api-getskillcontenttool)) that let a model discover and run what the catalogs hold. Everything emits OTel `ratel.*`/`gen_ai.*` spans plus a local trace stream (ADR-0007). ## Classes ### DimensionMismatchError A vector's dimension did not match the embedding cache's — the model changed under an existing corpus. A subclass of [EmbedderError](#api-embeddererror) (so `instanceof EmbedderError` still catches it), mirroring Python's `DimensionMismatchError`. Its [EmbedderError.code](#api-code) is `"DimensionMismatch"`. #### Extends * [`EmbedderError`](#api-embeddererror) #### Constructors ##### Constructor > **new DimensionMismatchError**(`message`): [`DimensionMismatchError`](#api-dimensionmismatcherror) ###### Parameters | Parameter | Type | Description | | --------- | -------- | ---------------------------------------------- | | `message` | `string` | The underlying dimension-mismatch description. | ###### Returns [`DimensionMismatchError`](#api-dimensionmismatcherror) ###### Overrides [`EmbedderError`](#api-embeddererror).[`constructor`](#api-constructor-1) #### Properties ##### cause? > `optional` **cause?**: `unknown` ###### Inherited from [`EmbedderError`](#api-embeddererror).[`cause`](#api-cause-1) ##### code > `readonly` **code**: `string` Stable machine-readable discriminant — one of `"Load"`, `"Download"`, `"NotCached"`, `"ModelMismatch"`, `"DimensionMismatch"`, `"EmbeddingsNotBuilt"`, `"Inference"`, or `"CacheUnwritable"`. Prefer this (or `instanceof`) over parsing [Error.message](#api-message). ###### Inherited from [`EmbedderError`](#api-embeddererror).[`code`](#api-code-1) ##### message > **message**: `string` ###### Inherited from [`EmbedderError`](#api-embeddererror).[`message`](#api-message-1) ##### name > **name**: `string` ###### Inherited from [`EmbedderError`](#api-embeddererror).[`name`](#api-name-1) ##### stack? > `optional` **stack?**: `string` ###### Inherited from [`EmbedderError`](#api-embeddererror).[`stack`](#api-stack-1) ##### stackTraceLimit > `static` **stackTraceLimit**: `number` The `Error.stackTraceLimit` property specifies the number of stack frames collected by a stack trace (whether generated by `new Error().stack` or `Error.captureStackTrace(obj)`). The default value is `10` but may be set to any valid JavaScript number. Changes will affect any stack trace captured *after* the value has been changed. If set to a non-number value, or set to a negative number, stack traces will not capture any frames. ###### Inherited from [`EmbedderError`](#api-embeddererror).[`stackTraceLimit`](#api-stacktracelimit-1) #### Methods ##### captureStackTrace() > `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void` Creates a `.stack` property on `targetObject`, which when accessed returns a string representing the location in the code at which `Error.captureStackTrace()` was called. ```js const myObject = {}; Error.captureStackTrace(myObject); myObject.stack; // Similar to `new Error().stack` ``` The first line of the trace will be prefixed with `${myObject.name}: ${myObject.message}`. The optional `constructorOpt` argument accepts a function. If given, all frames above `constructorOpt`, including `constructorOpt`, will be omitted from the generated stack trace. The `constructorOpt` argument is useful for hiding implementation details of error generation from the user. For instance: ```js function a() { b(); } function b() { c(); } function c() { // Create an error without stack trace to avoid calculating the stack trace twice. const { stackTraceLimit } = Error; Error.stackTraceLimit = 0; const error = new Error(); Error.stackTraceLimit = stackTraceLimit; // Capture the stack trace above function b Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace throw error; } a(); ``` ###### Parameters | Parameter | Type | | ----------------- | ---------- | | `targetObject` | `object` | | `constructorOpt?` | `Function` | ###### Returns `void` ###### Inherited from [`EmbedderError`](#api-embeddererror).[`captureStackTrace`](#api-capturestacktrace-1) ##### prepareStackTrace() > `static` **prepareStackTrace**(`err`, `stackTraces`): `any` ###### Parameters | Parameter | Type | | ------------- | ------------- | | `err` | `Error` | | `stackTraces` | `CallSite`\[] | ###### Returns `any` ###### See [https://v8.dev/docs/stack-trace-api#customizing-stack-traces](https://v8.dev/docs/stack-trace-api#customizing-stack-traces) ###### Inherited from [`EmbedderError`](#api-embeddererror).[`prepareStackTrace`](#api-preparestacktrace-1) *** ### EmbedderError An embedding model failed to load, download, or run — the base class for every dense-retrieval failure raised from `register` / `searchAsync` on a `"semantic"`/`"hybrid"` catalog. Mirrors Python's `EmbedderError`. #### Extends * `Error` #### Extended by * [`DimensionMismatchError`](#api-dimensionmismatcherror) #### Constructors ##### Constructor > **new EmbedderError**(`message`, `code`): [`EmbedderError`](#api-embeddererror) ###### Parameters | Parameter | Type | Description | | --------- | -------- | --------------------------------------------------------- | | `message` | `string` | The underlying failure description (the core error text). | | `code` | `string` | The stable [EmbedderError.code](#api-code) discriminant. | ###### Returns [`EmbedderError`](#api-embeddererror) ###### Overrides `Error.constructor` #### Properties ##### cause? > `optional` **cause?**: `unknown` ###### Inherited from `Error.cause` ##### code > `readonly` **code**: `string` Stable machine-readable discriminant — one of `"Load"`, `"Download"`, `"NotCached"`, `"ModelMismatch"`, `"DimensionMismatch"`, `"EmbeddingsNotBuilt"`, `"Inference"`, or `"CacheUnwritable"`. Prefer this (or `instanceof`) over parsing [Error.message](#api-message). ##### message > **message**: `string` ###### Inherited from `Error.message` ##### name > **name**: `string` ###### Inherited from `Error.name` ##### stack? > `optional` **stack?**: `string` ###### Inherited from `Error.stack` ##### stackTraceLimit > `static` **stackTraceLimit**: `number` The `Error.stackTraceLimit` property specifies the number of stack frames collected by a stack trace (whether generated by `new Error().stack` or `Error.captureStackTrace(obj)`). The default value is `10` but may be set to any valid JavaScript number. Changes will affect any stack trace captured *after* the value has been changed. If set to a non-number value, or set to a negative number, stack traces will not capture any frames. ###### Inherited from `Error.stackTraceLimit` #### Methods ##### captureStackTrace() > `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void` Creates a `.stack` property on `targetObject`, which when accessed returns a string representing the location in the code at which `Error.captureStackTrace()` was called. ```js const myObject = {}; Error.captureStackTrace(myObject); myObject.stack; // Similar to `new Error().stack` ``` The first line of the trace will be prefixed with `${myObject.name}: ${myObject.message}`. The optional `constructorOpt` argument accepts a function. If given, all frames above `constructorOpt`, including `constructorOpt`, will be omitted from the generated stack trace. The `constructorOpt` argument is useful for hiding implementation details of error generation from the user. For instance: ```js function a() { b(); } function b() { c(); } function c() { // Create an error without stack trace to avoid calculating the stack trace twice. const { stackTraceLimit } = Error; Error.stackTraceLimit = 0; const error = new Error(); Error.stackTraceLimit = stackTraceLimit; // Capture the stack trace above function b Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace throw error; } a(); ``` ###### Parameters | Parameter | Type | | ----------------- | ---------- | | `targetObject` | `object` | | `constructorOpt?` | `Function` | ###### Returns `void` ###### Inherited from `Error.captureStackTrace` ##### prepareStackTrace() > `static` **prepareStackTrace**(`err`, `stackTraces`): `any` ###### Parameters | Parameter | Type | | ------------- | ------------- | | `err` | `Error` | | `stackTraces` | `CallSite`\[] | ###### Returns `any` ###### See [https://v8.dev/docs/stack-trace-api#customizing-stack-traces](https://v8.dev/docs/stack-trace-api#customizing-stack-traces) ###### Inherited from `Error.prepareStackTrace` *** ### IntentGraph A shared usage-ranking intent graph (ADR-0014): clusters of past queries, each remembering the capabilities invoked after them. **Hand the same instance to both registries.** One cluster carries a `tools` map and a `skills` map, so a tool catalog and a skill catalog sharing a graph learn from and rank against one set of clusters. Giving them separate graphs duplicates every cluster and halves the evidence behind each. #### Constructors ##### Constructor > **new IntentGraph**(): [`IntentGraph`](#api-intentgraph) An empty graph — knows nothing until a search is followed by an invoke. ###### Returns [`IntentGraph`](#api-intentgraph) #### Accessors ##### clusterCount ###### Get Signature > **get** **clusterCount**(): `number` How many clusters the graph holds. `0` is the cold-start state, in which the graph contributes nothing to ranking. ###### Returns `number` ##### rev ###### Get Signature > **get** **rev**(): `number` Monotonic write counter — bumped once per mutation (a confirmed observation, a rebuild). Never affects ranking; it is a primitive for your storage layer. Snapshot it after each save: a later value means unsaved learning (save-when-changed), and a stored graph whose `rev` is higher than the one you loaded was written by another process (stale-base detection). ###### Returns `number` #### Methods ##### toJson() > **toJson**(): `string` Serialize to the `protocol/v1` wire form — for inspection, or to carry what was learned across processes. The graph is in-process only; persistence is yours. It mutates on every confirmed invoke, so unsaved observations are lost on a crash — persist on a cadence or at shutdown. Use `rev` to save only when it changed and to detect a concurrent writer; single-writer is the supported model. SENSITIVE: the output contains the raw text of past user queries (the cluster `members`). Treat a persisted graph like your query/telemetry log — restrict permissions (`0600`), keep it out of version control and images, and do not ship it to a less-trusted store. ###### Returns `string` ##### fromJson() > `static` **fromJson**(`json`): [`IntentGraph`](#api-intentgraph) Adopt a graph serialized in the `protocol/v1` wire form — produced by a previous `toJson` or by Ratel Cloud. Throws if the JSON is malformed or declares a schema version this build does not read (a consumer rejects rather than degrading). ###### Parameters | Parameter | Type | | --------- | -------- | | `json` | `string` | ###### Returns [`IntentGraph`](#api-intentgraph) *** ### McpToolsListError Paginated MCP `tools/list` failed — repeated cursor or page cap exceeded. Thrown by [registerMcpServer](#api-registermcpserver) when listing upstream tools; mirrors Python's `McpToolsListError`. #### Extends * `Error` #### Constructors ##### Constructor > **new McpToolsListError**(`message`, `code`): [`McpToolsListError`](#api-mcptoolslisterror) ###### Parameters | Parameter | Type | Description | | --------- | ------------------------------------------------------- | -------------------------------------------------------------------------- | | `message` | `string` | Human-readable failure description. | | `code` | [`McpToolsListErrorCode`](#api-mcptoolslisterrorcode-1) | Stable [McpToolsListErrorCode](#api-mcptoolslisterrorcode-1) discriminant. | ###### Returns [`McpToolsListError`](#api-mcptoolslisterror) ###### Overrides `Error.constructor` #### Properties ##### cause? > `optional` **cause?**: `unknown` ###### Inherited from `Error.cause` ##### code > `readonly` **code**: [`McpToolsListErrorCode`](#api-mcptoolslisterrorcode-1) Prefer [McpToolsListErrorCode](#api-mcptoolslisterrorcode-1) (or `instanceof`) over parsing [Error.message](#api-message). ##### message > **message**: `string` ###### Inherited from `Error.message` ##### name > **name**: `string` ###### Inherited from `Error.name` ##### stack? > `optional` **stack?**: `string` ###### Inherited from `Error.stack` ##### stackTraceLimit > `static` **stackTraceLimit**: `number` The `Error.stackTraceLimit` property specifies the number of stack frames collected by a stack trace (whether generated by `new Error().stack` or `Error.captureStackTrace(obj)`). The default value is `10` but may be set to any valid JavaScript number. Changes will affect any stack trace captured *after* the value has been changed. If set to a non-number value, or set to a negative number, stack traces will not capture any frames. ###### Inherited from `Error.stackTraceLimit` #### Methods ##### captureStackTrace() > `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void` Creates a `.stack` property on `targetObject`, which when accessed returns a string representing the location in the code at which `Error.captureStackTrace()` was called. ```js const myObject = {}; Error.captureStackTrace(myObject); myObject.stack; // Similar to `new Error().stack` ``` The first line of the trace will be prefixed with `${myObject.name}: ${myObject.message}`. The optional `constructorOpt` argument accepts a function. If given, all frames above `constructorOpt`, including `constructorOpt`, will be omitted from the generated stack trace. The `constructorOpt` argument is useful for hiding implementation details of error generation from the user. For instance: ```js function a() { b(); } function b() { c(); } function c() { // Create an error without stack trace to avoid calculating the stack trace twice. const { stackTraceLimit } = Error; Error.stackTraceLimit = 0; const error = new Error(); Error.stackTraceLimit = stackTraceLimit; // Capture the stack trace above function b Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace throw error; } a(); ``` ###### Parameters | Parameter | Type | | ----------------- | ---------- | | `targetObject` | `object` | | `constructorOpt?` | `Function` | ###### Returns `void` ###### Inherited from `Error.captureStackTrace` ##### prepareStackTrace() > `static` **prepareStackTrace**(`err`, `stackTraces`): `any` ###### Parameters | Parameter | Type | | ------------- | ------------- | | `err` | `Error` | | `stackTraces` | `CallSite`\[] | ###### Returns `any` ###### See [https://v8.dev/docs/stack-trace-api#customizing-stack-traces](https://v8.dev/docs/stack-trace-api#customizing-stack-traces) ###### Inherited from `Error.prepareStackTrace` *** ### SkillCatalog In-memory catalog of skills, ranked by the native `SkillRegistry` retrieval engine. The on-demand analog of [ToolCatalog](#api-toolcatalog): registered skills are searched by relevance; the matching body is fetched only on [SkillCatalog.invoke](#api-invoke). #### Constructors ##### Constructor > **new SkillCatalog**(`options?`): [`SkillCatalog`](#api-skillcatalog) Create an empty catalog. ###### Parameters | Parameter | Type | Description | | --------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `options` | [`SkillCatalogOptions`](#api-skillcatalogoptions) | Trace sink, default retrieval method, and embedding model. Construction validates configuration but never loads a model. | ###### Returns [`SkillCatalog`](#api-skillcatalog) #### Accessors ##### experimentalAdaptiveRankingStatus ###### Get Signature > **get** **experimentalAdaptiveRankingStatus**(): [`AdaptiveRankingStatus`](#api-adaptiverankingstatus) Whether adaptive usage ranking is active, inactive, or paused by a model change — see the native `AdaptiveRankingStatus`. ###### Returns [`AdaptiveRankingStatus`](#api-adaptiverankingstatus) #### Methods ##### drainTraceEvents() > **drainTraceEvents**(): `unknown`\[] Drain the envelopes captured by a `"memory"` trace sink, emptying its buffer. Same contract as `ToolCatalog.drainTraceEvents`. ###### Returns `unknown`\[] The captured envelopes in record order; `[]` unless the active sink is `"memory"`. ##### experimentalDisableAdaptiveRanking() > **experimentalDisableAdaptiveRanking**(): `void` Turn adaptive usage ranking off; the graph keeps what it learned. ###### Returns `void` ##### experimentalEnableAdaptiveRanking() > **experimentalEnableAdaptiveRanking**(`graph`, `options?`): `void` Turn on adaptive usage ranking against `graph` (ADR-0014): the catalog ranks against what users have actually invoked after similar queries, and keeps learning as it is used. Pass the same [IntentGraph](#api-intentgraph) to a [ToolCatalog](#api-toolcatalog) so both learn into one set of clusters. Set `rebuildOnModelChange` to auto-recover a model-mismatched graph on the next dense (semantic/hybrid) search rather than staying paused until you call [experimentalRebuildIntentGraph](#api-experimentalrebuildintentgraph) yourself. Off by default — the rebuild is an embedding pass (cost, possible `EmbedderError`, and it mutates the graph). ###### Parameters | Parameter | Type | | ------------------------------- | --------------------------------------------------------------------------- | | `graph` | [`IntentGraph`](#api-intentgraph) | | `options` | \{ `rebuildOnModelChange?`: `boolean`; `warnOnModelMismatch?`: `boolean`; } | | `options.rebuildOnModelChange?` | `boolean` | | `options.warnOnModelMismatch?` | `boolean` | ###### Returns `void` ##### experimentalRebuildIntentGraph() > **experimentalRebuildIntentGraph**(): `Promise`\<`void`> Re-embed the intent graph's members under the current model and replace its centroids — call after changing the embedding model. Preserves members, support, and edges. See [experimentalEnableAdaptiveRanking](#api-experimentalenableadaptiveranking). ###### Returns `Promise`\<`void`> ##### get() > **get**(`skillId`): [`Skill`](#api-skill) | `undefined` Look up a skill by id. ###### Parameters | Parameter | Type | Description | | --------- | -------- | ------------------ | | `skillId` | `string` | The id to look up. | ###### Returns [`Skill`](#api-skill) | `undefined` The skill as registered (including `body`), or `undefined` for an unknown id. ##### has() > **has**(`skillId`): `boolean` Whether a skill with this id is registered. ###### Parameters | Parameter | Type | Description | | --------- | -------- | ------------------ | | `skillId` | `string` | The id to look up. | ###### Returns `boolean` `true` if [SkillCatalog.invoke](#api-invoke) would find it. ##### invoke() > **invoke**(`skillId`): `string` Return a skill's body for dispatch, recording a `skill_invoke` event. Throws on an unknown id — callers at the capability-tool boundary translate that into a structured error for the agent. ###### Parameters | Parameter | Type | Description | | --------- | -------- | ------------------------- | | `skillId` | `string` | Id of a registered skill. | ###### Returns `string` The skill's `body` (`""` when it was registered without one). ##### recordEvent() > **recordEvent**(`event`): `void` Record a custom event on the local trace stream (ADR-0007). Same contract as `ToolCatalog.recordEvent`: the event is a tagged wire-shape object (`{ type: "...", ... }`, snake\_case fields) and an unknown shape throws. ###### Parameters | Parameter | Type | Description | | --------- | -------- | -------------------------- | | `event` | `object` | The trace event to record. | ###### Returns `void` ##### register() > **register**(`skills`): `Promise`\<`void`> Add one skill or a batch to the catalog — the single entry point for both. Replaces an id in place when already registered. Name, description, and tags are indexed for ranking; `tools`, `metadata`, and `body` are stored but not indexed (`body` is the dispatch payload, fetched by [SkillCatalog.invoke](#api-invoke)). On a `"semantic"`/`"hybrid"` catalog, embeds the batch in one pass on a libuv worker after metadata is indexed — embedding errors surface **here**, at registration. A `"bm25"` catalog never loads a model. A model or dimension change is not recovered in place — construct a new catalog and re-register. ###### Parameters | Parameter | Type | Description | | --------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | | `skills` | [`Skill`](#api-skill) \| readonly [`Skill`](#api-skill)\[] | A single skill or a readonly array of them. Pass the whole batch at once for a single embedding request. | ###### Returns `Promise`\<`void`> ##### replaceAll() > **replaceAll**(`skills`): [`PendingReplace`](#api-pendingreplace) Make `skills` the entire content of the catalog: ids absent from the batch are removed, the rest are added or updated. The whole-catalog counterpart to [SkillCatalog.register](#api-register), for a source that reloads a catalog rather than pushing individual changes. Mutates in place, so every holder of this catalog — `ratel()`'s `r.skills`, each adapted view, and the capability tools taken from `modelTools()` — observes the reload without being rebuilt. **Removal is unconditional.** Skills registered in-process are dropped just like any others, since the batch *is* the catalog. A host that keeps local skills alongside a remote source composes the batch itself: `await catalog.replaceAll([...localSkills, ...remoteSkills])`. Embedding follows the same two-phase contract as [SkillCatalog.register](#api-register): the corpus swap lands first, then the batch is embedded. Only genuinely new or re-worded skills are embedded — an unchanged id keeps its vector, so reloading an unchanged catalog costs no embedding calls at all. If the embedding pass fails, the new corpus is already live and BM25 still ranks it; semantic search reports `EmbeddingsNotBuilt` until a later pass succeeds. A concurrent operation refuses the call outright rather than applying it half-way, so two overlapping reloads can never blend into one corpus. Because the swap is the synchronous half, that refusal throws at the call site rather than rejecting the returned promise. Note that any in-flight [SkillCatalog.searchAsync](#api-searchasync) can trigger it, including a plain BM25 one — `registry busy` is retryable and not exclusive to dense work. ###### Parameters | Parameter | Type | Description | | --------- | --------------------------------- | ----------------------------------------------------------------------------------------------------- | | `skills` | readonly [`Skill`](#api-skill)\[] | The complete catalog contents. A repeated id keeps its last entry. An empty array clears the catalog. | ###### Returns [`PendingReplace`](#api-pendingreplace) The counts, already final, over a promise for the embedding pass — see [PendingReplace](#api-pendingreplace). `.added`/`.removed` can be read before that pass settles, but the value must still always be awaited (or `.catch()`-ed), or an embedding failure becomes an unhandled rejection. ##### search() > **search**(`query`, `topK`, `origin?`, `method?`): [`SkillHit`](#api-skillhit)\[] Search the catalog synchronously with BM25. A `"semantic"`/`"hybrid"` call throws synchronously with guidance to use [SkillCatalog.searchAsync](#api-searchasync). ###### Parameters | Parameter | Type | Default value | Description | | --------- | ----------------------------------- | ------------- | --------------------------------------------------------------------------------------------------------- | | `query` | `string` | `undefined` | Natural-language description of the task at hand. | | `topK` | `number` | `undefined` | Maximum number of hits to return. | | `origin` | [`SearchOrigin`](#api-searchorigin) | `"direct"` | Who initiated the call (default `"direct"`); recorded on the trace event and span, never affects ranking. | | `method?` | [`SearchMethod`](#api-searchmethod) | `undefined` | Per-call override of the catalog's default retrieval method. | ###### Returns [`SkillHit`](#api-skillhit)\[] Up to `topK` BM25 hits, best-first with ties broken by skill id. Semantic/dense/hybrid methods throw migration guidance; use [SkillCatalog.searchAsync](#api-searchasync) for those methods. ##### searchAsync() > **searchAsync**(`query`, `topK`, `origin?`, `method?`): `Promise`\<[`SkillHit`](#api-skillhit)\[]> Search with any retrieval method without blocking the Node.js event loop. ###### Parameters | Parameter | Type | Default value | | --------- | ----------------------------------- | ------------- | | `query` | `string` | `undefined` | | `topK` | `number` | `undefined` | | `origin` | [`SearchOrigin`](#api-searchorigin) | `"direct"` | | `method?` | [`SearchMethod`](#api-searchmethod) | `undefined` | ###### Returns `Promise`\<[`SkillHit`](#api-skillhit)\[]> ##### size() > **size**(): `number` Number of registered skills (distinct ids). `searchCapabilitiesTool` uses this to decide whether to advertise a `skills` bucket at all. ###### Returns `number` The count. *** ### SkillRegistry Typed facade over the native skill registry — the skill twin of [ToolRegistry](#api-toolregistry). [SkillCatalog](#api-skillcatalog) is the higher-level surface; reach for this directly only when bare metadata is enough. #### Constructors ##### Constructor > **new SkillRegistry**(`embedding?`, `method?`): [`SkillRegistry`](#api-skillregistry) Create a registry with an optional embedding model and retrieval method. ###### Parameters | Parameter | Type | Default value | Description | | ------------ | ------------------------------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `embedding?` | [`EmbeddingSpec`](#api-embeddingspec) | `undefined` | Embedding model for semantic/hybrid retrieval — see [ToolRegistry.constructor](#api-constructor-7). | | `method?` | [`SearchMethod`](#api-searchmethod) | `"bm25"` | `"bm25"` (default, model-free) or `"semantic"`/`"hybrid"`, which makes [SkillRegistry.register](#api-register-1) embed the batch inline. | ###### Returns [`SkillRegistry`](#api-skillregistry) #### Accessors ##### experimentalAdaptiveRankingStatus ###### Get Signature > **get** **experimentalAdaptiveRankingStatus**(): [`AdaptiveRankingStatus`](#api-adaptiverankingstatus) Whether adaptive usage ranking is contributing (`"active"`), off (`"inactive"`), not yet determinable (`"unknown"`), or paused because the embedding model changed (`"paused: dim mismatch"` / `"paused: model mismatch"`). Gate on this instead of reading stderr if you prefer. ###### Returns [`AdaptiveRankingStatus`](#api-adaptiverankingstatus) #### Methods ##### drainTraceEvents() > **drainTraceEvents**(): `unknown`\[] Drain captured envelopes from a `"memory"` sink; `[]` otherwise. ###### Returns `unknown`\[] ##### experimentalDisableAdaptiveRanking() > **experimentalDisableAdaptiveRanking**(): `void` Turn adaptive usage ranking off: ranking returns to the base engine and the graph stops growing. The graph keeps what it learned, so re-enabling resumes rather than restarts. ###### Returns `void` ##### experimentalEnableAdaptiveRanking() > **experimentalEnableAdaptiveRanking**(`graph`, `options?`): `void` Turn on adaptive usage ranking against `graph` (ADR-0014). Wires both halves: the registry ranks against the graph, and its trace sink is decorated with a learner that grows it from search-then-invoke pairs — a capability the user actually invoked after a query becomes evidence for similar queries later. Pass the **same** [IntentGraph](#api-intentgraph) to the tool and skill registries. One cluster holds both a tool and a skill edge map, so sharing gives one set of clusters with all the evidence behind it; separate graphs duplicate every cluster and split the evidence. Only queries that match a cluster are affected — anything else ranks exactly as it would have. With a graph attached, `SearchHit.score` becomes a fusion score rather than a raw BM25 score, so use `rank` for ordering and `fused` to detect the scale, not the raw `score`. ###### Parameters | Parameter | Type | | ------------------------------- | --------------------------------------------------------------------------- | | `graph` | [`IntentGraph`](#api-intentgraph) | | `options` | \{ `rebuildOnModelChange?`: `boolean`; `warnOnModelMismatch?`: `boolean`; } | | `options.rebuildOnModelChange?` | `boolean` | | `options.warnOnModelMismatch?` | `boolean` | ###### Returns `void` ##### experimentalRebuildIntentGraph() > **experimentalRebuildIntentGraph**(): `Promise`\<`void`> Re-embed the intent graph's members under the current embedding model and replace its centroids. Call after changing the model: a graph's centroids are only comparable to queries from the model that built them, so on a swap the usage arm pauses until this runs. Members, support, and edges are preserved — only the centroids move to the new space. ###### Returns `Promise`\<`void`> ##### recordEvent() > **recordEvent**(`event`): `void` Record a custom event on the local trace stream (ADR-0007). ###### Parameters | Parameter | Type | | --------- | -------- | | `event` | `object` | ###### Returns `void` ##### register() > **register**(`item`): `Promise`\<`void`> Register one skill or a batch, replacing any existing id in place — see [ToolRegistry.register](#api-register-3) for the embed-inside contract. ###### Parameters | Parameter | Type | Description | | --------- | ---------------------------------------------------------- | --------------------------------------------------------- | | `item` | [`Skill`](#api-skill) \| readonly [`Skill`](#api-skill)\[] | A single [Skill](#api-skill) or a readonly array of them. | ###### Returns `Promise`\<`void`> ##### search() > **search**(`query`, `topK`): [`SkillHit`](#api-skillhit)\[] Lexical BM25 search over skills — see `ToolRegistry.search`. ###### Parameters | Parameter | Type | | --------- | -------- | | `query` | `string` | | `topK` | `number` | ###### Returns [`SkillHit`](#api-skillhit)\[] ##### searchWithMethod() > **searchWithMethod**(`query`, `topK`, `origin`, `method`): [`SkillHit`](#api-skillhit)\[] Synchronous search restricted to BM25 — see `ToolRegistry.searchWithMethod`. ###### Parameters | Parameter | Type | | --------- | ----------------------------------- | | `query` | `string` | | `topK` | `number` | | `origin` | [`SearchOrigin`](#api-searchorigin) | | `method` | [`SearchMethod`](#api-searchmethod) | ###### Returns [`SkillHit`](#api-skillhit)\[] ##### searchWithMethodAsync() > **searchWithMethodAsync**(`query`, `topK`, `origin`, `method`): `Promise`\<[`SkillHit`](#api-skillhit)\[]> Search on a libuv worker — see `ToolRegistry.searchWithMethodAsync`. ###### Parameters | Parameter | Type | | --------- | ----------------------------------- | | `query` | `string` | | `topK` | `number` | | `origin` | [`SearchOrigin`](#api-searchorigin) | | `method` | [`SearchMethod`](#api-searchmethod) | ###### Returns `Promise`\<[`SkillHit`](#api-skillhit)\[]> ##### searchWithOrigin() > **searchWithOrigin**(`query`, `topK`, `origin`): [`SkillHit`](#api-skillhit)\[] BM25 search with an explicit trace origin. ###### Parameters | Parameter | Type | | --------- | ----------------------------------- | | `query` | `string` | | `topK` | `number` | | `origin` | [`SearchOrigin`](#api-searchorigin) | ###### Returns [`SkillHit`](#api-skillhit)\[] ##### setTraceSink() > **setTraceSink**(`config`): `void` Replace the trace sink; subsequent events go to the new destination. ###### Parameters | Parameter | Type | | --------- | ----------------------------------------- | | `config` | [`TraceSinkConfig`](#api-tracesinkconfig) | ###### Returns `void` *** ### ToolCatalog In-process catalog of executable tools, ranked by the native Rust registry. The SDK's central surface: [ToolCatalog.register](#api-register-2) tools (or ingest an MCP server's via `registerMcpServer`), [ToolCatalog.search](#api-search-2) them by relevance, and [ToolCatalog.invoke](#api-invoke-1) the chosen one. Every operation emits both an OTel span (to whatever provider is active — see telemetry.ts) and a local trace event (to the sink from [ToolCatalogOptions.trace](#api-trace-2)), per ADR-0007. #### Example ```ts import { ToolCatalog } from "@ratel-ai/sdk"; import { readFile } from "node:fs/promises"; const catalog = new ToolCatalog(); await 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" }, execute: async ({ path }) => ({ contents: await readFile(path, "utf8") }), }); const [hit] = catalog.search("read a file from disk", 5); const result = await catalog.invoke(hit.toolId, { path: "/tmp/notes.txt" }); ``` #### Constructors ##### Constructor > **new ToolCatalog**(`options?`): [`ToolCatalog`](#api-toolcatalog) Create an empty catalog. ###### Parameters | Parameter | Type | Description | | --------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `options` | [`ToolCatalogOptions`](#api-toolcatalogoptions) | Trace sink, default retrieval method, and embedding model. Construction validates configuration but never loads a model. | ###### Returns [`ToolCatalog`](#api-toolcatalog) #### Accessors ##### experimentalAdaptiveRankingStatus ###### Get Signature > **get** **experimentalAdaptiveRankingStatus**(): [`AdaptiveRankingStatus`](#api-adaptiverankingstatus) Whether adaptive usage ranking is active, inactive, or paused by a model change — see the native `AdaptiveRankingStatus`. ###### Returns [`AdaptiveRankingStatus`](#api-adaptiverankingstatus) #### Methods ##### drainTraceEvents() > **drainTraceEvents**(): `unknown`\[] Drain the envelopes captured by a `"memory"` trace sink, emptying its buffer. ###### Returns `unknown`\[] The captured envelopes (`{ v, ts, session_id, type, ... }` — the event fields are flattened alongside the envelope stamp) in record order. Always `[]` unless the active sink is `"memory"`. ##### experimentalDisableAdaptiveRanking() > **experimentalDisableAdaptiveRanking**(): `void` Turn adaptive usage ranking off; the graph keeps what it learned. ###### Returns `void` ##### experimentalEnableAdaptiveRanking() > **experimentalEnableAdaptiveRanking**(`graph`, `options?`): `void` Turn on adaptive usage ranking against `graph` (ADR-0014): the catalog ranks against what users have actually invoked after similar queries, and keeps learning as it is used. Pass the same [IntentGraph](#api-intentgraph) to a [SkillCatalog](#api-skillcatalog) so both learn into one set of clusters. Set `rebuildOnModelChange` to auto-recover a model-mismatched graph on the next dense (semantic/hybrid) search rather than staying paused until you call [experimentalRebuildIntentGraph](#api-experimentalrebuildintentgraph-2) yourself. Off by default — the rebuild is an embedding pass (cost, possible `EmbedderError`, and it mutates the graph). ###### Parameters | Parameter | Type | | ------------------------------- | --------------------------------------------------------------------------- | | `graph` | [`IntentGraph`](#api-intentgraph) | | `options` | \{ `rebuildOnModelChange?`: `boolean`; `warnOnModelMismatch?`: `boolean`; } | | `options.rebuildOnModelChange?` | `boolean` | | `options.warnOnModelMismatch?` | `boolean` | ###### Returns `void` ##### experimentalRebuildIntentGraph() > **experimentalRebuildIntentGraph**(): `Promise`\<`void`> Re-embed the intent graph's members under the current model and replace its centroids — call after changing the embedding model. Preserves members, support, and edges. See [experimentalEnableAdaptiveRanking](#api-experimentalenableadaptiveranking-2). ###### Returns `Promise`\<`void`> ##### get() > **get**(`toolId`): [`Tool`](#api-tool) | `undefined` Look up a tool's searchable metadata (no executor attached). ###### Parameters | Parameter | Type | Description | | --------- | -------- | ------------------ | | `toolId` | `string` | The id to look up. | ###### Returns [`Tool`](#api-tool) | `undefined` The metadata as registered, or `undefined` for an unknown id. ##### getExecutable() > **getExecutable**(`toolId`): [`ExecutableTool`](#api-executabletool) | `undefined` Look up a tool with its executor reattached. ###### Parameters | Parameter | Type | Description | | --------- | -------- | ------------------ | | `toolId` | `string` | The id to look up. | ###### Returns [`ExecutableTool`](#api-executabletool) | `undefined` A copy of the registered tool including `execute`, or `undefined` for an unknown id. ##### has() > **has**(`toolId`): `boolean` Whether a tool with this id is registered. ###### Parameters | Parameter | Type | Description | | --------- | -------- | ------------------ | | `toolId` | `string` | The id to look up. | ###### Returns `boolean` `true` if [ToolCatalog.invoke](#api-invoke-1) would find an executor for it. ##### invoke() > **invoke**(`toolId`, `args`, `context?`): `Promise`\<`unknown`> Run a registered tool's executor. Sync-absorbing: a plain value or `Promise` settles through the returned promise, and a synchronous `throw` surfaces as a rejection — `invoke` never throws synchronously, including for an unknown `toolId` (that rejects with `unknown toolId: …`). An `AsyncIterable` is the resolved value; its instrumentation settles when the iterator completes, is cancelled, or fails. The call is wrapped in an `execute_tool` OTel span and bracketed by `invoke_start` / `invoke_end` (or `invoke_error`, with the error message) events on the local trace stream, `took_ms` in wall-clock milliseconds. ###### Parameters | Parameter | Type | Description | | ---------- | ------------------------------ | --------------------------------------------------------------------- | | `toolId` | `string` | Id of a registered tool. | | `args` | `Record`\<`string`, `unknown`> | Arguments object validated and possibly transformed before execution. | | `context?` | `unknown` | Optional opaque invocation context forwarded unchanged. | ###### Returns `Promise`\<`unknown`> Whatever the executor returns (resolved if it returned a promise). ##### invokeRaw() > **invokeRaw**(`toolId`, `args`, `context?`): `unknown` Run a registered tool without erasing its immediate return shape after synchronous validation. A plain value stays plain, a promise stays a promise, and an `AsyncIterable` stays synchronously iterable. An async validator necessarily makes this method return a promise of the executor's result. Instrumentation settles only when that returned shape settles (including iterator completion, cancellation, or failure). Most callers should use [invoke](#api-invoke-1); capability-tool bridges use this path so a host framework can observe streamed preliminary outputs. ###### Parameters | Parameter | Type | | ---------- | ------------------------------ | | `toolId` | `string` | | `args` | `Record`\<`string`, `unknown`> | | `context?` | `unknown` | ###### Returns `unknown` ##### invokeValidatedRaw() > **invokeValidatedRaw**(`toolId`, `input`, `context?`): `unknown` Execute input already parsed by [validateInput](#api-validateinput), preserving its immediate scalar, promise, or `AsyncIterable` return shape. Framework bridges call this only after their host has run the capability tool's live validator; ordinary callers should use [invoke](#api-invoke-1). ###### Parameters | Parameter | Type | | ---------- | --------- | | `toolId` | `string` | | `input` | `unknown` | | `context?` | `unknown` | ###### Returns `unknown` ##### recordEvent() > **recordEvent**(`event`): `void` Record a custom event on the local trace stream (ADR-0007), e.g. an `upstream_register` from an ingestion layer. Delivered to the sink configured at construction; a no-op sink discards it. ###### Parameters | Parameter | Type | Description | | --------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `event` | `object` | A tagged trace event in wire shape: `{ type: "...", ... }` with snake\_case fields. Throws if the object is not a known trace event. | ###### Returns `void` ##### register() > **register**(`tools`): `Promise`\<`void`> Add one tool or a batch to the catalog — the single entry point for both. Replaces an id in place when already registered (metadata, executor, and index entry; the corpus never holds a duplicate). On a `"semantic"`/`"hybrid"` catalog, embeds the batch in one pass on a libuv worker after metadata is indexed, so the event loop is never blocked; embedding errors (model load / endpoint / auth / dimension) surface **here**, at registration — metadata still persists even if the embedding pass that follows fails. A `"bm25"` catalog never loads a model and resolves as soon as metadata is indexed. A model or dimension change is not recovered in place — construct a new catalog and re-register. ###### Parameters | Parameter | Type | Description | | --------- | ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `tools` | [`ExecutableTool`](#api-executabletool) \| readonly [`ExecutableTool`](#api-executabletool)\[] | A single tool or a readonly array of tools; each `execute` must be set. Pass the whole batch at once for a single embedding request — separate `register` calls embed separately. | ###### Returns `Promise`\<`void`> ###### Throws [EmbedderError](#api-embeddererror) on a `"semantic"`/`"hybrid"` catalog when embedding fails (model load / endpoint / auth / dimension) — a [DimensionMismatchError](#api-dimensionmismatcherror) for a vector-width change. A missing `execute` handler throws a plain `Error`. ##### search() > **search**(`query`, `topK`, `origin?`, `method?`): [`SearchHit`](#api-searchhit)\[] Search the catalog. `method` overrides the catalog default for this call. `"semantic"`/`"hybrid"` rank against the prebuilt embedding cache and throw synchronously with guidance to use [ToolCatalog.searchAsync](#api-searchasync-1). ###### Parameters | Parameter | Type | Default value | Description | | --------- | ----------------------------------- | ------------- | --------------------------------------------------------------------------------------------------------- | | `query` | `string` | `undefined` | Natural-language description of what the caller wants to do. | | `topK` | `number` | `undefined` | Maximum number of hits to return. | | `origin` | [`SearchOrigin`](#api-searchorigin) | `"direct"` | Who initiated the call (default `"direct"`); recorded on the trace event and span, never affects ranking. | | `method?` | [`SearchMethod`](#api-searchmethod) | `undefined` | Per-call override of the catalog's default retrieval method. | ###### Returns [`SearchHit`](#api-searchhit)\[] Up to `topK` BM25 hits, best-first with ties broken by tool id. Semantic/dense/hybrid methods throw migration guidance; use [ToolCatalog.searchAsync](#api-searchasync-1) for those methods. ##### searchAsync() > **searchAsync**(`query`, `topK`, `origin?`, `method?`): `Promise`\<[`SearchHit`](#api-searchhit)\[]> Search with any retrieval method without blocking the Node.js event loop. ###### Parameters | Parameter | Type | Default value | | --------- | ----------------------------------- | ------------- | | `query` | `string` | `undefined` | | `topK` | `number` | `undefined` | | `origin` | [`SearchOrigin`](#api-searchorigin) | `"direct"` | | `method?` | [`SearchMethod`](#api-searchmethod) | `undefined` | ###### Returns `Promise`\<[`SearchHit`](#api-searchhit)\[]> ##### validateInput() > **validateInput**(`toolId`, `input`): [`InputValidationResult`](#api-inputvalidationresult) | `Promise`\<[`InputValidationResult`](#api-inputvalidationresult)> Run a registered framework validator without executing the tool. A tool without one succeeds with its input unchanged. The returned success value is the exact input the executor should receive, including any defaults or root-level transformation. ###### Parameters | Parameter | Type | | --------- | --------- | | `toolId` | `string` | | `input` | `unknown` | ###### Returns [`InputValidationResult`](#api-inputvalidationresult) | `Promise`\<[`InputValidationResult`](#api-inputvalidationresult)> *** ### ToolRegistry Typed facade over the native tool registry: metadata-only indexing and retrieval, with the SDK's public embedding config and an async, batch-aware `register`. [ToolCatalog](#api-toolcatalog) layers executors, OTel spans, and defaults on top; reach for this directly only when bare metadata (no executors) is enough. #### Constructors ##### Constructor > **new ToolRegistry**(`embedding?`, `method?`): [`ToolRegistry`](#api-toolregistry) Create a registry with an optional embedding model and retrieval method. ###### Parameters | Parameter | Type | Default value | Description | | ------------ | ------------------------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `embedding?` | [`EmbeddingSpec`](#api-embeddingspec) | `undefined` | Embedding model for semantic/hybrid retrieval; a bare string is a local model directory path. Validated at construction, never loaded eagerly here. | | `method?` | [`SearchMethod`](#api-searchmethod) | `"bm25"` | `"bm25"` (default, model-free) or `"semantic"`/`"hybrid"`, which makes [ToolRegistry.register](#api-register-3) embed the batch inline. | ###### Returns [`ToolRegistry`](#api-toolregistry) #### Accessors ##### experimentalAdaptiveRankingStatus ###### Get Signature > **get** **experimentalAdaptiveRankingStatus**(): [`AdaptiveRankingStatus`](#api-adaptiverankingstatus) Whether adaptive usage ranking is contributing (`"active"`), off (`"inactive"`), not yet determinable (`"unknown"`), or paused because the embedding model changed (`"paused: dim mismatch"` / `"paused: model mismatch"`). Gate on this instead of reading stderr if you prefer. ###### Returns [`AdaptiveRankingStatus`](#api-adaptiverankingstatus) #### Methods ##### drainTraceEvents() > **drainTraceEvents**(): `unknown`\[] Drain captured envelopes from a `"memory"` sink; `[]` otherwise. ###### Returns `unknown`\[] ##### experimentalDisableAdaptiveRanking() > **experimentalDisableAdaptiveRanking**(): `void` Turn adaptive usage ranking off: ranking returns to the base engine and the graph stops growing. The graph keeps what it learned, so re-enabling resumes rather than restarts. ###### Returns `void` ##### experimentalEnableAdaptiveRanking() > **experimentalEnableAdaptiveRanking**(`graph`, `options?`): `void` Turn on adaptive usage ranking against `graph` (ADR-0014). Wires both halves: the registry ranks against the graph, and its trace sink is decorated with a learner that grows it from search-then-invoke pairs — a capability the user actually invoked after a query becomes evidence for similar queries later. Pass the **same** [IntentGraph](#api-intentgraph) to the tool and skill registries. One cluster holds both a tool and a skill edge map, so sharing gives one set of clusters with all the evidence behind it; separate graphs duplicate every cluster and split the evidence. Only queries that match a cluster are affected — anything else ranks exactly as it would have. With a graph attached, `SearchHit.score` becomes a fusion score rather than a raw BM25 score, so use `rank` for ordering and `fused` to detect the scale, not the raw `score`. ###### Parameters | Parameter | Type | | ------------------------------- | --------------------------------------------------------------------------- | | `graph` | [`IntentGraph`](#api-intentgraph) | | `options` | \{ `rebuildOnModelChange?`: `boolean`; `warnOnModelMismatch?`: `boolean`; } | | `options.rebuildOnModelChange?` | `boolean` | | `options.warnOnModelMismatch?` | `boolean` | ###### Returns `void` ##### experimentalRebuildIntentGraph() > **experimentalRebuildIntentGraph**(): `Promise`\<`void`> Re-embed the intent graph's members under the current embedding model and replace its centroids. Call after changing the model: a graph's centroids are only comparable to queries from the model that built them, so on a swap the usage arm pauses until this runs. Members, support, and edges are preserved — only the centroids move to the new space. ###### Returns `Promise`\<`void`> ##### recordEvent() > **recordEvent**(`event`): `void` Record a custom event on the local trace stream (ADR-0007). Throws on an object that doesn't parse as a known trace event. ###### Parameters | Parameter | Type | | --------- | -------- | | `event` | `object` | ###### Returns `void` ##### register() > **register**(`item`): `Promise`\<`void`> Register one tool or a batch, replacing any existing id in place — the corpus never holds a duplicate. On a `"semantic"`/`"hybrid"` registry, embeds the whole batch in one pass on a libuv worker after metadata is indexed, so the event loop is never blocked; awaiting surfaces embedding errors here. A `"bm25"` registry resolves as soon as metadata is indexed and never loads a model. ###### Parameters | Parameter | Type | Description | | --------- | ------------------------------------------------------ | ------------------------------------------------------- | | `item` | [`Tool`](#api-tool) \| readonly [`Tool`](#api-tool)\[] | A single [Tool](#api-tool) or a readonly array of them. | ###### Returns `Promise`\<`void`> ##### search() > **search**(`query`, `topK`): [`SearchHit`](#api-searchhit)\[] Lexical BM25 search: up to `topK` hits, best-first with ties broken by id. Model-free and infallible; records the query on the local trace stream with origin `"direct"`. ###### Parameters | Parameter | Type | | --------- | -------- | | `query` | `string` | | `topK` | `number` | ###### Returns [`SearchHit`](#api-searchhit)\[] ##### searchWithMethod() > **searchWithMethod**(`query`, `topK`, `origin`, `method`): [`SearchHit`](#api-searchhit)\[] Synchronous search restricted to BM25; `"semantic"`/`"hybrid"` throw with guidance to use [ToolRegistry.searchWithMethodAsync](#api-searchwithmethodasync-1). ###### Parameters | Parameter | Type | | --------- | ----------------------------------- | | `query` | `string` | | `topK` | `number` | | `origin` | [`SearchOrigin`](#api-searchorigin) | | `method` | [`SearchMethod`](#api-searchmethod) | ###### Returns [`SearchHit`](#api-searchhit)\[] ##### searchWithMethodAsync() > **searchWithMethodAsync**(`query`, `topK`, `origin`, `method`): `Promise`\<[`SearchHit`](#api-searchhit)\[]> Search on a libuv worker; supports `"bm25"`, `"semantic"`, and `"hybrid"`. ###### Parameters | Parameter | Type | | --------- | ----------------------------------- | | `query` | `string` | | `topK` | `number` | | `origin` | [`SearchOrigin`](#api-searchorigin) | | `method` | [`SearchMethod`](#api-searchmethod) | ###### Returns `Promise`\<[`SearchHit`](#api-searchhit)\[]> ##### searchWithOrigin() > **searchWithOrigin**(`query`, `topK`, `origin`): [`SearchHit`](#api-searchhit)\[] BM25 search with an explicit trace origin; ranking is unaffected. ###### Parameters | Parameter | Type | | --------- | ----------------------------------- | | `query` | `string` | | `topK` | `number` | | `origin` | [`SearchOrigin`](#api-searchorigin) | ###### Returns [`SearchHit`](#api-searchhit)\[] ##### setTraceSink() > **setTraceSink**(`config`): `void` Replace the trace sink; subsequent events go to the new destination. ###### Parameters | Parameter | Type | | --------- | ----------------------------------------- | | `config` | [`TraceSinkConfig`](#api-tracesinkconfig) | ###### Returns `void` ## Interfaces ### AdaptedBase The framework-shaped surface every adapter inherits from the core. Adapters add their idioms via [RatelAdapter.extend](#api-extend); universal capability lives here. #### Type Parameters | Type Parameter | | -------------- | | `TTool` | | `TMessage` | #### Properties ##### skills > `readonly` **skills**: [`SkillCatalog`](#api-skillcatalog) The shared skill catalog (skills are framework-neutral). ##### tools > `readonly` **tools**: [`AdaptedToolCollection`](#api-adaptedtoolcollection)\<`TTool`> This view's handle over the shared catalog, in the framework's tool shape. #### Methods ##### modelTools() > **modelTools**(): `Record`\<`string`, `TTool`> The model-facing toolset in the framework's shape: this view's passthroughs plus the three capability tools run through the adapter's `expose` codec. Fresh objects per call — take it once per agent instance and reuse it, so the prompt cache survives. Tools registered later are still discoverable (the capability tools search the live catalog); only a *passthrough* registered later needs a fresh `modelTools()` to reach the model. ###### Returns `Record`\<`string`, `TTool`> ##### recall() > **recall**(`query`): `Promise`\<`TMessage`\[]> Rank `query` and return the synthetic `search_capabilities` message pair in the framework's shape (origin `"direct"`), or `[]` when nothing matched (spending no call id). Pure: it builds fresh messages and never mutates a host array. ###### Parameters | Parameter | Type | | --------- | -------- | | `query` | `string` | ###### Returns `Promise`\<`TMessage`\[]> *** ### AdaptedToolCollection An adapted view's handle over the same shared catalog, speaking the framework's tool shape. Registration runs the adapter's `ingest` codec; first registration of an id wins across every view of the core, including ids claimed by framework-local passthroughs, so repeated calls are idempotent. `register`/`has` are framework-aware (`register` takes the framework's tool shape; `has` also covers this view's passthroughs); `get`/`search`/`invoke` are at parity with [ToolCollection](#api-toolcollection) — they read and run the shared catalog in its neutral shapes, so they're catalog-only: a passthrough is provider-executed and un-indexed, so `has` reports it but `get` returns `undefined`, `search` never ranks it, and `invoke` can't run it. #### Type Parameters | Type Parameter | | -------------- | | `TTool` | #### Properties ##### catalog > `readonly` **catalog**: [`ToolCatalog`](#api-toolcatalog) The shared catalog itself — the unguarded driver-level escape hatch. #### Methods ##### get() > **get**(`id`): [`Tool`](#api-tool) | `undefined` A catalog tool's searchable metadata, or `undefined` (incl. for a passthrough). ###### Parameters | Parameter | Type | | --------- | -------- | | `id` | `string` | ###### Returns [`Tool`](#api-tool) | `undefined` ##### has() > **has**(`id`): `boolean` Whether this id is registered — in the catalog or as this view's passthrough. ###### Parameters | Parameter | Type | | --------- | -------- | | `id` | `string` | ###### Returns `boolean` ##### invoke() > **invoke**(`id`, `args`): `Promise`\<`unknown`> Execute a catalog tool by id with the args object (not a passthrough). ###### Parameters | Parameter | Type | | --------- | ------------------------------ | | `id` | `string` | | `args` | `Record`\<`string`, `unknown`> | ###### Returns `Promise`\<`unknown`> ##### register() > **register**(`tools`): `Promise`\<`void`> Ingest framework tools (keyed by tool id) into the shared catalog. Async, with the same semantics as [ToolCollection.register](#api-register-5): ids are validated and ingested synchronously (a reserved id throws at the call site), then the returned promise resolves when the batch is indexed and, on a semantic/hybrid core, embedded — rejecting if embedding fails. `await` it before a dense search. ###### Parameters | Parameter | Type | | --------- | ---------------------------- | | `tools` | `Record`\<`string`, `TTool`> | ###### Returns `Promise`\<`void`> ##### search() > **search**(`query`, `topK`, `method?`): [`SearchHit`](#api-searchhit)\[] Rank the shared catalog for `query` synchronously (host-driven, origin `"direct"`). BM25 only — semantic/hybrid throws with a pointer to [searchAsync](#api-searchasync-2). `topK` is clamped to `[1, 50]` (invalid values fall back to 5); passthroughs are never ranked. Drop to [catalog](#api-catalog) for an unclamped search. ###### Parameters | Parameter | Type | | --------- | ----------------------------------- | | `query` | `string` | | `topK` | `number` | | `method?` | [`SearchMethod`](#api-searchmethod) | ###### Returns [`SearchHit`](#api-searchhit)\[] ##### searchAsync() > **searchAsync**(`query`, `topK`, `method?`): `Promise`\<[`SearchHit`](#api-searchhit)\[]> Rank the shared catalog for `query` with any retrieval method off the event loop (origin `"direct"`). Same `topK` clamp as [search](#api-search-4); passthroughs are never ranked. `await register(...)` first so a dense tool is embedded. ###### Parameters | Parameter | Type | | --------- | ----------------------------------- | | `query` | `string` | | `topK` | `number` | | `method?` | [`SearchMethod`](#api-searchmethod) | ###### Returns `Promise`\<[`SearchHit`](#api-searchhit)\[]> *** ### AdaptiveRankingStatus SDK-facing view of whether adaptive usage ranking is contributing. `status` is `"active" | "inactive" | "unknown" | "paused: dim mismatch" | "paused: model mismatch"`; `built`/`active`/`dimMismatch` are set only when paused. #### Properties ##### active? > `optional` **active?**: `string` When paused: the currently active embedding model (or its dimension). Absent unless paused. ##### built? > `optional` **built?**: `string` When paused: the embedding model (or its dimension) the graph's centroids were built with. Absent unless paused. ##### dimMismatch? > `optional` **dimMismatch?**: `boolean` When paused: `true` if the mismatch is a dimension difference, `false` if it is a same-dimension model-identity difference. Absent unless paused. ##### status > **status**: `string` One of `"active" | "inactive" | "unknown" | "paused: dim mismatch" | "paused: model mismatch"`. *** ### CapabilitiesSearchOptions Options for [runCapabilitiesSearch](#api-runcapabilitiessearch). #### Properties ##### origin? > `optional` **origin?**: [`SearchOrigin`](#api-searchorigin) Who initiated the search — stamped on the `gateway_search` trace event and the `ratel.search` span. Default `"agent"` (a model-synthesized call); the host-driven recall path passes `"direct"`. ##### skillCatalog? > `optional` **skillCatalog?**: [`SkillCatalog`](#api-skillcatalog) Catalog the `skills` bucket is ranked from (and whose declared tool deps ride in). ##### topKSkills? > `optional` **topKSkills?**: `number` Max skills bucket size; capped at 50, default 3; invalid values fall back to the default. ##### topKTools? > `optional` **topKTools?**: `number` Max tools bucket size; capped at 50, default 5 (same as the tool); 0, negative, or non-integer values fall back to the default. ##### upstreamServers? > `optional` **upstreamServers?**: readonly [`UpstreamServerInfo`](#api-upstreamserverinfo)\[] Upstream-server metadata attached to the matching result groups. *** ### CapabilitySkillHit One ranked skill in the `skills` bucket of [SearchCapabilitiesResult](#api-searchcapabilitiesresult). #### Properties ##### description > **description**: `string` The skill's description, compacted to \~160 chars for the listing. ##### score > **score**: `number` Retrieval score from the skill catalog's search (BM25 by default). ##### skillId > **skillId**: `string` Skill id to pass to `get_skill_content`. *** ### CapabilityToolGroup Tool hits grouped by upstream server. The group key is the `__` id prefix (a plain, un-prefixed tool id groups under itself); `description` and `instructions` are attached when a matching [UpstreamServerInfo](#api-upstreamserverinfo) was provided. #### Properties ##### hits > **hits**: [`CapabilityToolHit`](#api-capabilitytoolhit)\[] The server's ranked hits, in overall result order. ##### server > **server**: `object` The owning server: its name, plus optional description/instructions metadata. ###### description? > `optional` **description?**: `string` The server's one-line summary, when known. ###### instructions? > `optional` **instructions?**: `string` The server's usage instructions, when known. ###### name > **name**: `string` Server name (the `__` prefix of the group's tool ids). *** ### CapabilityToolHit One ranked tool in the `tools` bucket of [SearchCapabilitiesResult](#api-searchcapabilitiesresult). #### Properties ##### description > **description**: `string` The tool's description, as registered. ##### inputSchema > **inputSchema**: `Record`\<`string`, `unknown`> The tool's input JSON Schema, so the model can call it without another lookup. ##### score > **score**: `number` Retrieval score from the tool catalog's search (scale depends on the catalog's method — BM25 by default), or `0` when the tool was pulled in as a matched skill's declared dependency rather than by the query itself. ##### toolId > **toolId**: `string` Catalog id to pass to `invoke_tool` (`__` for MCP-proxied tools). *** ### CatalogRegistration A framework tool ingested into the catalog: what an adapter's [RatelAdapter.ingest](#api-ingest) returns for an executable tool. The core registers it verbatim (id/name are the app's tool key), so the schemas are the SDK's public [JSONSchema7](#api-jsonschema7) spelling and adapters need no casts. #### Properties ##### description > **description**: `string` Retrieval ranks on this; resolve dynamic descriptions at ingest time. ##### inputSchema > **inputSchema**: [`JSONSchema7`](#api-jsonschema7) Input JSON Schema (the catalog's native spelling). ##### outputSchema? > `optional` **outputSchema?**: [`JSONSchema7`](#api-jsonschema7) Output JSON Schema; defaults to `{ type: "object" }` when omitted. ##### validateInput? > `optional` **validateInput?**: [`InputValidator`](#api-inputvalidator) Framework-native parser retained in the shared catalog. Capability-tool exposure delegates to this live parser, so every adapted view observes native replacements and root-level transformations. #### Methods ##### execute() > **execute**(`input`, `context?`): `unknown` Runs the tool through the capability funnel with args and optional opaque adapter context. An adapter that supports live framework context tags it in `expose` and validates that private tag here before unwrapping it; a missing or foreign tag must take the framework's context-free fallback. ###### Parameters | Parameter | Type | | ---------- | --------- | | `input` | `unknown` | | `context?` | `unknown` | ###### Returns `unknown` *** ### ExecutableTool A tool the catalog can both retrieve *and* run: the searchable metadata of a `Tool` (id, name, description, schemas) plus its [Executor](#api-executor). The unit [ToolCatalog.register](#api-register-2) accepts and [ToolCatalog.getExecutable](#api-getexecutable) returns. #### Extends * [`Tool`](#api-tool) #### Properties ##### description > **description**: `string` What the tool does and when to use it — the main ranking signal. ###### Inherited from [`Tool`](#api-tool).[`description`](#api-description-7) ##### execute > **execute**: [`Executor`](#api-executor) Runs the tool. Called by [ToolCatalog.invoke](#api-invoke-1) with args and optional context. ##### id > **id**: `string` Unique id, the registry key. Re-registering an existing id replaces the entry in place. MCP-proxied tools use the `__` convention. ###### Inherited from [`Tool`](#api-tool).[`id`](#api-id-3) ##### inputSchema > **inputSchema**: [`JSONSchema7`](#api-jsonschema7) JSON Schema of the arguments. Property names and their `description`s (nested included) are indexed for ranking. ###### Inherited from [`Tool`](#api-tool).[`inputSchema`](#api-inputschema-4) ##### name > **name**: `string` Callable name (typically the same as `id` for local tools); indexed for ranking both whole and split on `snake_case`/`camelCase` boundaries. ###### Inherited from [`Tool`](#api-tool).[`name`](#api-name-7) ##### outputSchema > **outputSchema**: [`JSONSchema7`](#api-jsonschema7) JSON Schema of the result; indexed the same way as `inputSchema`. ###### Inherited from [`Tool`](#api-tool).[`outputSchema`](#api-outputschema-2) ##### validateInput? > `optional` **validateInput?**: [`InputValidator`](#api-inputvalidator) Shared parser used before execution and by model-facing host bridges. *** ### InvokeToolError A generic `invoke_tool` error whose original target failure is available to framework adapters. #### Properties ##### \[INVOKE\_TOOL\_ERROR\_CAUSE] > `readonly` **\[INVOKE\_TOOL\_ERROR\_CAUSE]**: `unknown` Original thrown value, hidden from enumeration and serialization. ##### error > **error**: `string` Human-readable failure passed to the model. ##### isError > **isError**: `true` Stable discriminator used by generic capability hosts. *** ### InvokeToolToolOptions Options for [invokeToolTool](#api-invoketooltool). #### Properties ##### onUnauthorized? > `optional` **onUnauthorized?**: (`upstream`) => `void` | `Promise`\<`void`> Notified when the underlying tool throws UnauthorizedError, with the upstream name inferred from the toolId. ###### Parameters | Parameter | Type | | ---------- | -------- | | `upstream` | `string` | ###### Returns `void` | `Promise`\<`void`> *** ### JSONSchema7 #### Properties ##### $comment? > `optional` **$comment?**: `string` ##### $defs? > `optional` **$defs?**: `object` ###### Index Signature \[`key`: `string`]: `JSONSchema7Definition` ###### See * [https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-00#section-8.2.4](https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-00#section-8.2.4) * [https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-validation-00#appendix-A](https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-validation-00#appendix-A) ##### $id? > `optional` **$id?**: `string` ##### $ref? > `optional` **$ref?**: `string` ##### $schema? > `optional` **$schema?**: `string` ##### additionalItems? > `optional` **additionalItems?**: `JSONSchema7Definition` ##### additionalProperties? > `optional` **additionalProperties?**: `JSONSchema7Definition` ##### allOf? > `optional` **allOf?**: `JSONSchema7Definition`\[] ###### See [https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.7](https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.7) ##### anyOf? > `optional` **anyOf?**: `JSONSchema7Definition`\[] ##### const? > `optional` **const?**: `JSONSchema7Type` ##### contains? > `optional` **contains?**: `JSONSchema7Definition` ##### contentEncoding? > `optional` **contentEncoding?**: `string` ##### contentMediaType? > `optional` **contentMediaType?**: `string` ###### See [https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-8](https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-8) ##### default? > `optional` **default?**: `JSONSchema7Type` ##### definitions? > `optional` **definitions?**: `object` ###### Index Signature \[`key`: `string`]: `JSONSchema7Definition` ###### See [https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-9](https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-9) ##### dependencies? > `optional` **dependencies?**: `object` ###### Index Signature \[`key`: `string`]: `string`\[] | `JSONSchema7Definition` ##### description? > `optional` **description?**: `string` ##### else? > `optional` **else?**: `JSONSchema7Definition` ##### enum? > `optional` **enum?**: `JSONSchema7Type`\[] ##### examples? > `optional` **examples?**: `JSONSchema7Type` ##### exclusiveMaximum? > `optional` **exclusiveMaximum?**: `number` ##### exclusiveMinimum? > `optional` **exclusiveMinimum?**: `number` ##### format? > `optional` **format?**: `string` ###### See [https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-7](https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-7) ##### if? > `optional` **if?**: `JSONSchema7Definition` ###### See [https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.6](https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.6) ##### items? > `optional` **items?**: `JSONSchema7Definition` | `JSONSchema7Definition`\[] ###### See [https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.4](https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.4) ##### maximum? > `optional` **maximum?**: `number` ##### maxItems? > `optional` **maxItems?**: `number` ##### maxLength? > `optional` **maxLength?**: `number` ###### See [https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.3](https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.3) ##### maxProperties? > `optional` **maxProperties?**: `number` ###### See [https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.5](https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.5) ##### minimum? > `optional` **minimum?**: `number` ##### minItems? > `optional` **minItems?**: `number` ##### minLength? > `optional` **minLength?**: `number` ##### minProperties? > `optional` **minProperties?**: `number` ##### multipleOf? > `optional` **multipleOf?**: `number` ###### See [https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.2](https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.2) ##### not? > `optional` **not?**: `JSONSchema7Definition` ##### oneOf? > `optional` **oneOf?**: `JSONSchema7Definition`\[] ##### pattern? > `optional` **pattern?**: `string` ##### patternProperties? > `optional` **patternProperties?**: `object` ###### Index Signature \[`key`: `string`]: `JSONSchema7Definition` ##### properties? > `optional` **properties?**: `object` ###### Index Signature \[`key`: `string`]: `JSONSchema7Definition` ##### propertyNames? > `optional` **propertyNames?**: `JSONSchema7Definition` ##### readOnly? > `optional` **readOnly?**: `boolean` ##### required? > `optional` **required?**: `string`\[] ##### then? > `optional` **then?**: `JSONSchema7Definition` ##### title? > `optional` **title?**: `string` ###### See [https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-10](https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-10) ##### type? > `optional` **type?**: `JSONSchema7TypeName` | `JSONSchema7TypeName`\[] ###### See [https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.1](https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.1) ##### uniqueItems? > `optional` **uniqueItems?**: `boolean` ##### writeOnly? > `optional` **writeOnly?**: `boolean` *** ### McpServerHandle What [registerMcpServer](#api-registermcpserver) returns: the ingested ids plus lifecycle control. #### Properties ##### close > **close**: () => `Promise`\<`void`> Close the underlying MCP client connection. The proxied tools stay in the catalog but invoking them after close fails. ###### Returns `Promise`\<`void`> ##### serverInstructions > **serverInstructions**: `string` | `undefined` The usage instructions the server declared during the MCP initialize handshake, or `undefined` if it declared none. Useful as `UpstreamServerInfo.instructions` when building capability tools. ##### toolIds > **toolIds**: `string`\[] Namespaced ids in upstream list order (all pages). Duplicate names may appear more than once; [ToolCatalog.register](#api-register-2) keeps the last row. *** ### Ratel One `ratel(config)` core: a single [ToolCatalog](#api-toolcatalog) + [SkillCatalog](#api-skillcatalog) * recall-id counter shared by every [Ratel.adaptTo](#api-adaptto) view. Genuinely usable standalone — register native tools on [Ratel.tools](#api-tools-1), hand the model [Ratel.modelTools](#api-modeltools-1), rank with [Ratel.recall](#api-recall-1) — and adaptable on top for a framework's native shapes. #### Properties ##### skills > `readonly` **skills**: [`SkillCatalog`](#api-skillcatalog) The shared skill catalog — exposed raw (skills are framework-neutral and need no ingest codec), so it is the unguarded escape hatch at parity with [ToolCollection.catalog](#api-catalog-1): its `search` top-K is not clamped. Use [recall](#api-recall-1) for a clamped, capability-shaped skills ranking. ##### tools > `readonly` **tools**: [`ToolCollection`](#api-toolcollection) Handle over the shared tool catalog (native shapes, guarded). #### Methods ##### adaptTo() > **adaptTo**\<`A`>(`adapter`): [`AdaptedRatel`](#api-adaptedratel)\<`A`> Adapt the core to a framework, inferring its tool/message types and helpers. ###### Type Parameters | Type Parameter | | ----------------------------------------------------------------------------------------------------- | | `A` *extends* [`RatelAdapter`](#api-rateladapter)\<`unknown`, `unknown`, `Record`\<`never`, `never`>> | ###### Parameters | Parameter | Type | | --------- | ---- | | `adapter` | `A` | ###### Returns [`AdaptedRatel`](#api-adaptedratel)\<`A`> ##### modelTools() > **modelTools**(): `Record`\<`string`, [`ExecutableTool`](#api-executabletool)> The three capability tools (`search_capabilities`, `invoke_tool`, `get_skill_content`) in native shape, for framework-free hosts. All three are always advertised — the set never depends on registration order, so the prompt cache survives; loading a skill from an empty catalog returns a structured error, not a missing tool. Fresh objects per call: take it once and reuse it. Tools and skills registered later are still discoverable. ###### Returns `Record`\<`string`, [`ExecutableTool`](#api-executabletool)> ##### recall() > **recall**(`query`): `Promise`\<[`SearchCapabilitiesResult`](#api-searchcapabilitiesresult) | `null`> Rank `query` into the canonical `search_capabilities` result (origin `"direct"`, top-K from `recallTopK`), or `null` when nothing matched. A pure query: no call id is minted — ids exist only on the adapted views, whose synthetic message pairs need them. Ranks whatever is registered and (on a dense core) embedded now — `await r.tools.register(...)` first. ###### Parameters | Parameter | Type | | --------- | -------- | | `query` | `string` | ###### Returns `Promise`\<[`SearchCapabilitiesResult`](#api-searchcapabilitiesresult) | `null`> *** ### RatelAdapter The framework boundary: one complementary package per framework implements this so Ratel speaks that framework's native tool and message shapes. The three codecs (`ingest` / `expose` / `recallMessages`) are the whole contract; `extend` adds framework idioms. The core owns all state and guards, so an adapter is \~three pure functions. #### Type Parameters | Type Parameter | Default type | Description | | ------------------------- | --------------------------- | --------------------------------------------------------------- | | `TTool` | `unknown` | The framework's tool type (e.g. AI SDK `Tool`). | | `TMessage` | `unknown` | The framework's message type (e.g. AI SDK `ModelMessage`). | | `TExt` *extends* `object` | `Record`\<`never`, `never`> | The framework-idiomatic helpers merged onto the adapted object. | #### Properties ##### name > `readonly` **name**: `string` Names the adapter in error messages (and, once adapter packages emit it, in the `ratel.adapter` telemetry attribute — stamping is deferred to them per ADR-0013). #### Methods ##### expose() > **expose**(`tool`): `TTool` Ratel capability tool → framework tool. Context-aware adapters wrap the framework's complete live execution context in a private, stable tag before passing it as the capability executor's optional second argument. ###### Parameters | Parameter | Type | | --------- | --------------------------------------- | | `tool` | [`ExecutableTool`](#api-executabletool) | ###### Returns `TTool` ##### extend()? > `optional` **extend**(`base`): `TExt` Framework-idiomatic helpers, merged onto the adapted object. ###### Parameters | Parameter | Type | | --------- | ------------------------------------------------------- | | `base` | [`AdaptedBase`](#api-adaptedbase)\<`TTool`, `TMessage`> | ###### Returns `TExt` ##### ingest() > **ingest**(`id`, `tool`): [`CatalogRegistration`](#api-catalogregistration) | `"passthrough"` Framework tool → catalog registration, or `"passthrough"` for tools the catalog can't execute (provider-executed) that must stay eagerly exposed. ###### Parameters | Parameter | Type | | --------- | -------- | | `id` | `string` | | `tool` | `TTool` | ###### Returns [`CatalogRegistration`](#api-catalogregistration) | `"passthrough"` ##### recallMessages() > **recallMessages**(`ref`, `recall`): `TMessage`\[] Synthetic recall pair in the framework's message shape. ###### Parameters | Parameter | Type | | --------- | ----------------------------------------------------------- | | `ref` | [`RecallRef`](#api-recallref) | | `recall` | [`SearchCapabilitiesResult`](#api-searchcapabilitiesresult) | ###### Returns `TMessage`\[] *** ### RatelConfig Construction options for [ratel](#api-ratel-1). Shared by every adapter view of the core. #### Properties ##### embedding? > `optional` **embedding?**: [`EmbeddingSpec`](#api-embeddingspec) Embedding model backing `"semantic"`/`"hybrid"` retrieval, forwarded to both catalogs — see [ToolCatalogOptions.embedding](#api-embedding-2). A string is a local model directory path; every other source is a keyed object (`{ huggingface }`, `{ ollama }`, `{ url, model, apiKeyEnv }`). Omit to use the built-in default model. `await r.tools.register(...)` awaits the embedding pass and rejects if it fails, so errors surface at registration. ##### method? > `optional` **method?**: [`SearchMethod`](#api-searchmethod) Default retrieval method for the tool and skill catalogs (default `"bm25"`, model-free). ##### recallTopK? > `optional` **recallTopK?**: `number` Max tools each host-driven `recall` returns: capped at 50; 0, negative, or non-integer values fall back to the default 5. ##### trace? > `optional` **trace?**: [`TraceSinkConfig`](#api-tracesinkconfig) Local trace-stream destination for both catalogs (default: discard). *** ### RecallRef The identity of one synthetic recall call, handed to [RatelAdapter.recallMessages](#api-recallmessages). #### Properties ##### callId > **callId**: `string` Unique call id from the core's private counter (never a transcript position). ##### query > **query**: `string` The recall query (the last user turn's text, in the AI SDK adapter). *** ### RegisterMcpServerOptions Options for [registerMcpServer](#api-registermcpserver). #### Properties ##### name > **name**: `string` Namespace for the server's tools inside the catalog: each tool is registered as `__`. Also the `server` name that trace events and result groups report for these tools. ##### transport > **transport**: `Transport` An MCP client transport for the server (e.g. `StdioClientTransport`, `StreamableHTTPClientTransport`, or an `InMemoryTransport` pair in tests). [registerMcpServer](#api-registermcpserver) connects it; it must not be connected already. *** ### ReplaceOutcome What a `SkillRegistry.replaceAll` changed, counted by id. `updated` covers any field edit (including a body-only rewrite); `unchanged` ids are identical to what was registered and keep their cached embedding. #### Properties ##### added > **added**: `number` Ids in the new corpus that were not in the old one. ##### removed > **removed**: `number` Ids in the old corpus that are absent from the new one. ##### unchanged > **unchanged**: `number` Ids present in both with identical content. ##### updated > **updated**: `number` Ids present in both whose content differs in any field. *** ### SearchCapabilitiesOptions Options for [searchCapabilitiesTool](#api-searchcapabilitiestool). #### Properties ##### advertiseSkills? > `optional` **advertiseSkills?**: `boolean` Override the skills clause in the tool description. Default: present only when the skill catalog is non-empty at build time. Hosts that always expose `get_skill_content` (the `ratel()` facade) pass `true` so the description is byte-identical regardless of when the first skill registers. ##### upstreamServers? > `optional` **upstreamServers?**: readonly [`UpstreamServerInfo`](#api-upstreamserverinfo)\[] Upstream MCP servers to advertise in the tool description and result groups. *** ### SearchCapabilitiesResult Result shape of the `search_capabilities` tool: two independently-ranked buckets with separate top-K budgets. Scores are comparable within a bucket, not across the two (tools and skills are indexed as different text shapes). #### Properties ##### skills > **skills**: [`CapabilitySkillHit`](#api-capabilityskillhit)\[] Skill hits — playbooks to load via `get_skill_content`. ##### tools > **tools**: `object` Executable tool hits, grouped by upstream server. ###### groups > **groups**: [`CapabilityToolGroup`](#api-capabilitytoolgroup)\[] Groups in ranking order (a server appears where its best hit ranked). *** ### SearchHit One ranked tool from a registry search, best-first. #### Properties ##### fused > **fused**: `boolean` `true` when `score` is an RRF score (ordering-only) rather than the raw method score: the usage arm fused into this search, or the method is hybrid. Uniform across one result list; lets a caller detect the scale. ##### rank > **rank**: `number` 0-based position in this result list (best is `0`). Stable across methods and across the `fused` switch — the field to order or threshold on. ##### score > **score**: `number` Relevance score; higher is better, ties break by id ascending. Its scale depends on the method (raw BM25 / cosine / RRF) AND on `fused` — with adaptive ranking a matched query returns small RRF scores while an unmatched one on the same catalog returns the raw score. Order by `rank` and branch on `fused`; treat `score` as a within-list hint only. ##### toolId > **toolId**: `string` Id of the matched tool, as registered. *** ### ~~SearchToolHit~~ #### Deprecated Use `CapabilityToolHit`. #### Properties ##### ~~description~~ > **description**: `string` The tool's description, as registered. ##### ~~inputSchema~~ > **inputSchema**: `Record`\<`string`, `unknown`> The tool's input JSON Schema. ##### ~~score~~ > **score**: `number` Retrieval score from the catalog's search (BM25 by default). ##### ~~toolId~~ > **toolId**: `string` Catalog id to pass to `invoke_tool`. *** ### ~~SearchToolsGroup~~ #### Deprecated Use `CapabilityToolGroup`. #### Properties ##### ~~hits~~ > **hits**: [`SearchToolHit`](#api-searchtoolhit)\[] The server's ranked hits, in overall result order. ##### ~~server~~ > **server**: `object` The owning server: its name, plus optional description/instructions metadata. ###### ~~description?~~ > `optional` **description?**: `string` The server's one-line summary, when known. ###### ~~instructions?~~ > `optional` **instructions?**: `string` The server's usage instructions, when known. ###### ~~name~~ > **name**: `string` Server name (the `__` prefix of the group's tool ids). *** ### ~~SearchToolsResult~~ #### Deprecated Use `SearchCapabilitiesResult` (the `tools.groups` field). #### Properties ##### ~~groups~~ > **groups**: [`SearchToolsGroup`](#api-searchtoolsgroup)\[] Tool hits grouped by upstream server, in ranking order. *** ### ~~SearchToolsToolOptions~~ #### Deprecated Use `SearchCapabilitiesOptions`. #### Properties ##### ~~upstreamServers?~~ > `optional` **upstreamServers?**: readonly [`UpstreamServerInfo`](#api-upstreamserverinfo)\[] Upstream MCP servers to advertise in the tool description and result groups. *** ### Skill A reusable playbook: instructions the agent *reads* and follows, in contrast to a `Tool` it executes. Name, description, and tags are indexed for ranking; the `body` is the dispatch payload, deliberately excluded from the index so it can't drown the description's term weights. #### Properties ##### body? > `optional` **body?**: `string` The full instructions (Markdown) returned on load — the dispatch payload, never indexed for ranking. Optional (defaults to `""`) — parity with the Python SDK's default body. ##### description > **description**: `string` What the skill covers and when to reach for it — the main ranking signal. ##### id > **id**: `string` Unique id, the registry key. Re-registering an existing id replaces the entry in place. ##### metadata? > `optional` **metadata?**: `Record`\<`string`, `string`\[]> Free-form, non-indexed context for higher layers — e.g. `{ stacks: ["react"] }` for the push ranker to boost by project context. ##### name > **name**: `string` Human-readable name; indexed for ranking both whole and split on `snake_case`/`camelCase` boundaries. ##### tags? > `optional` **tags?**: `string`\[] Author-declared labels and task phrases ("frontend", "login form"); indexed for ranking. Optional (defaults to `[]`) — a minimal `Skill(id, name, description)` is valid, in parity with the Python SDK. ##### tools? > `optional` **tools?**: `string`\[] Ids of tools this skill's instructions call; surfaced into the `search_capabilities` tools bucket — not indexed as query terms. *** ### SkillCatalogOptions Construction options for [SkillCatalog](#api-skillcatalog). #### Properties ##### embedding? > `optional` **embedding?**: [`EmbeddingSpec`](#api-embeddingspec) Embedding model for semantic/hybrid retrieval — see [ToolCatalogOptions.embedding](#api-embedding-2). Retained for asynchronous overrides. ##### method? > `optional` **method?**: [`SearchMethod`](#api-searchmethod) Default retrieval method for `search` (default `"bm25"`). ##### trace? > `optional` **trace?**: [`TraceSinkConfig`](#api-tracesinkconfig) Local trace stream destination (default: discard). See [TraceSinkConfig](#api-tracesinkconfig). *** ### SkillHit One ranked skill from a registry search, best-first — the skill twin of `SearchHit`, with the same score semantics per method. #### Properties ##### fused > **fused**: `boolean` `true` when `score` is an RRF score — as on `SearchHit.fused`. ##### rank > **rank**: `number` 0-based position in this result list — as on `SearchHit.rank`. ##### score > **score**: `number` Relevance score; scale depends on the method and on `fused`, as on `SearchHit.score`. Order by `rank`, branch on `fused`. ##### skillId > **skillId**: `string` Id of the matched skill, as registered. *** ### Tool A tool's searchable metadata: what the registry indexes and what a search hit resolves back to. Execution lives a layer up (the SDK's `ToolCatalog` pairs each `Tool` with its executor). #### Extended by * [`ExecutableTool`](#api-executabletool) #### Properties ##### description > **description**: `string` What the tool does and when to use it — the main ranking signal. ##### id > **id**: `string` Unique id, the registry key. Re-registering an existing id replaces the entry in place. MCP-proxied tools use the `__` convention. ##### inputSchema > **inputSchema**: [`JSONSchema7`](#api-jsonschema7) JSON Schema of the arguments. Property names and their `description`s (nested included) are indexed for ranking. ##### name > **name**: `string` Callable name (typically the same as `id` for local tools); indexed for ranking both whole and split on `snake_case`/`camelCase` boundaries. ##### outputSchema > **outputSchema**: [`JSONSchema7`](#api-jsonschema7) JSON Schema of the result; indexed the same way as `inputSchema`. *** ### ToolCatalogOptions Construction options for [ToolCatalog](#api-toolcatalog). #### Properties ##### embedding? > `optional` **embedding?**: [`EmbeddingSpec`](#api-embeddingspec) Embedding model backing semantic/hybrid retrieval. A string is a local model directory path (`"/opt/models/bge"`); every other source is a keyed object: `{ huggingface: "BAAI/bge-base-en-v1.5" }`, `{ ollama: "…" }`, or `{ url, model, apiKeyEnv }`. Chosen once, used for both document and query embedding. Retained and validated even when the default method is `"bm25"`, allowing a later asynchronous semantic override. ##### method? > `optional` **method?**: [`SearchMethod`](#api-searchmethod) Default retrieval method for `search` (default `"bm25"`, model-free). A per-call `method` argument overrides it. ##### trace? > `optional` **trace?**: [`TraceSinkConfig`](#api-tracesinkconfig) Local trace stream destination (default: discard). See [TraceSinkConfig](#api-tracesinkconfig). *** ### ToolCollection The core's handle over its shared [ToolCatalog](#api-toolcatalog) — registration and lookup in the SDK's native shapes, callable at any time (also after [Ratel.modelTools](#api-modeltools-1): the capability tools search the live catalog at invocation time). Guards live here: the reserved capability-tool ids throw, and a framework-shaped tool throws an actionable install-the-adapter error. Registration keeps the catalog's own replace-in-place semantics — the native path is authoritative, unlike the first-wins adapted path — and embeds the batch on a semantic/hybrid catalog. #### Properties ##### catalog > `readonly` **catalog**: [`ToolCatalog`](#api-toolcatalog) The shared catalog itself — the unguarded driver-level escape hatch. #### Methods ##### get() > **get**(`id`): [`Tool`](#api-tool) | `undefined` The tool's searchable metadata, or `undefined` when unregistered. ###### Parameters | Parameter | Type | | --------- | -------- | | `id` | `string` | ###### Returns [`Tool`](#api-tool) | `undefined` ##### has() > **has**(`id`): `boolean` Whether a tool with this id is registered. ###### Parameters | Parameter | Type | | --------- | -------- | | `id` | `string` | ###### Returns `boolean` ##### invoke() > **invoke**(`id`, `args`): `Promise`\<`unknown`> Execute a registered tool by id with the args object. ###### Parameters | Parameter | Type | | --------- | ------------------------------ | | `id` | `string` | | `args` | `Record`\<`string`, `unknown`> | ###### Returns `Promise`\<`unknown`> ##### register() > **register**(...`tools`): `Promise`\<`void`> Register native tools (replace-in-place on a duplicate id). Async: input is validated synchronously (a missing `execute`, a reserved id, or a framework-shaped tool throws *at the call site*, before the promise), then the returned promise resolves when the batch is indexed and — on a `"semantic"`/`"hybrid"` core — embedded, rejecting if that embedding fails. `await` it before searching a dense core; embedding errors surface as the rejection. Pass the whole batch in one call for a single embedding pass. ###### Parameters | Parameter | Type | | ---------- | ------------------------------------------ | | ...`tools` | [`ExecutableTool`](#api-executabletool)\[] | ###### Returns `Promise`\<`void`> ##### search() > **search**(`query`, `topK`, `method?`): [`SearchHit`](#api-searchhit)\[] Rank the catalog for `query` synchronously (host-driven, origin `"direct"`). BM25 only: a `"semantic"`/`"hybrid"` catalog (or a per-call `method` override to one) throws with a pointer to [searchAsync](#api-searchasync-3), since dense ranking runs against the prebuilt embedding cache off the event loop. `topK` is clamped to `[1, 50]` (invalid values fall back to 5), like the capability funnel — drop to [catalog](#api-catalog-1) for an unclamped search. ###### Parameters | Parameter | Type | | --------- | ----------------------------------- | | `query` | `string` | | `topK` | `number` | | `method?` | [`SearchMethod`](#api-searchmethod) | ###### Returns [`SearchHit`](#api-searchhit)\[] ##### searchAsync() > **searchAsync**(`query`, `topK`, `method?`): `Promise`\<[`SearchHit`](#api-searchhit)\[]> Rank the catalog for `query` with any retrieval method without blocking the event loop (origin `"direct"`, same `topK` clamp as [search](#api-search-5)). Ranks whatever is embedded now — `await register(...)` first so a dense tool is in the cache. ###### Parameters | Parameter | Type | | --------- | ----------------------------------- | | `query` | `string` | | `topK` | `number` | | `method?` | [`SearchMethod`](#api-searchmethod) | ###### Returns `Promise`\<[`SearchHit`](#api-searchhit)\[]> *** ### UpstreamServerInfo Descriptive metadata about one upstream MCP server behind the catalog. Fed to [searchCapabilitiesTool](#api-searchcapabilitiestool) via [SearchCapabilitiesOptions](#api-searchcapabilitiesoptions): the servers are listed in the tool's description (via [formatUpstreamLine](#api-formatupstreamline)), and `description`/`instructions` enrich the matching [CapabilityToolGroup.server](#api-server) in results. #### Properties ##### description? > `optional` **description?**: `string` One-line summary of what the server offers; compacted in the listing. ##### instructions? > `optional` **instructions?**: `string` The server's own usage instructions (e.g. `McpServerHandle.serverInstructions`). ##### name > **name**: `string` Server name; must match the `name` the server was registered under (`registerMcpServer`), i.e. the `__` prefix of its tool ids. ##### needsAuth? > `optional` **needsAuth?**: `boolean` True when the upstream rejected its boot connection with 401 / requires re-authorization. ##### toolCount? > `optional` **toolCount?**: `number` Number of tools the server contributes; shown in the listing when set. ## Type Aliases ### AdaptedRatel > **AdaptedRatel**\<`A`> = `A` *extends* [`RatelAdapter`](#api-rateladapter)\ ? [`AdaptedBase`](#api-adaptedbase)\<`TTool`, `TMessage`> & `TExt` *extends* `object` ? `TExt` : `unknown` : `never` The object [Ratel.adaptTo](#api-adaptto) returns: the base surface plus the adapter's `extend` helpers. An adapter whose `extend` returns a non-object degrades to the bare base type here (instead of collapsing the whole view to `never`, which would surface as a cryptic error far from the broken adapter). #### Type Parameters | Type Parameter | | ------------------------------------------------- | | `A` *extends* [`RatelAdapter`](#api-rateladapter) | *** ### ContentCapture > **ContentCapture** = *typeof* [`ContentCapture`](#api-contentcapture-1)\[keyof *typeof* [`ContentCapture`](#api-contentcapture-1)] Message/tool content capture modes for `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` (CONVENTIONS.md § Capture gating). Default off. *** ### EmbeddingModelConfig > **EmbeddingModelConfig** = `ExclusiveEmbeddingFields`\<`"huggingface"` | `"revision"` | `"pooling"` | `"download"`> & `object` | `ExclusiveEmbeddingFields`\<`"local"` | `"pooling"`> & `object` | `ExclusiveEmbeddingFields`\<`"ollama"`> & `object` | `ExclusiveEmbeddingFields`\<`"url"` | `"model"` | `"apiKeyEnv"`> & `object` Object form of the embedding-model selection for semantic/hybrid retrieval. Each variant accepts exactly one source; fields from other variants are rejected at compile time. Use the bare string form only for a local model directory path. *** ### EmbeddingSpec > **EmbeddingSpec** = `string` | [`EmbeddingModelConfig`](#api-embeddingmodelconfig) Embedding-model selection: a bare string is a **local model directory path**; every other source is an explicit [EmbeddingModelConfig](#api-embeddingmodelconfig) object. *** ### Executor > **Executor** = (`input`, `context?`) => `Promise`\<`unknown`> | `unknown` The function that runs a tool. Receives the arguments object and an optional opaque invocation context supplied by a framework adapter. It may return a plain value, `Promise`, or `AsyncIterable`. [ToolCatalog.invoke](#api-invoke-1) absorbs synchronous values and throws into its promise contract; [ToolCatalog.invokeRaw](#api-invokeraw) preserves the immediate return shape when validation is synchronous, while [ToolCatalog.invokeValidatedRaw](#api-invokevalidatedraw) guarantees that shape after a host has already validated the input. One-argument executors remain valid; framework-neutral callers normally omit `context`. #### Parameters | Parameter | Type | | ---------- | --------- | | `input` | `any` | | `context?` | `unknown` | #### Returns `Promise`\<`unknown`> | `unknown` *** ### InputValidationResult > **InputValidationResult** = \{ `success`: `true`; `value`: `unknown`; } | \{ `error`: `Error`; `success`: `false`; } Result returned by a framework-native input validator. #### Union Members ##### Type Literal \{ `success`: `true`; `value`: `unknown`; } | Name | Type | Description | | --------- | --------- | ---------------------------------------------------- | | `success` | `true` | The input was accepted. | | `value` | `unknown` | Parsed value, including defaults or transformations. | *** ##### Type Literal \{ `error`: `Error`; `success`: `false`; } | Name | Type | Description | | --------- | ------- | ------------------------------------ | | `error` | `Error` | Framework-native validation failure. | | `success` | `false` | The input was rejected. | *** ### InputValidator > **InputValidator** = (`input`) => [`InputValidationResult`](#api-inputvalidationresult) | `PromiseLike`\<[`InputValidationResult`](#api-inputvalidationresult)> Optional framework-native parser retained by the shared catalog. It may validate, apply defaults, or transform the input before execution. #### Parameters | Parameter | Type | | --------- | --------- | | `input` | `unknown` | #### Returns [`InputValidationResult`](#api-inputvalidationresult) | `PromiseLike`\<[`InputValidationResult`](#api-inputvalidationresult)> *** ### McpToolsListErrorCode > **McpToolsListErrorCode** = `"RepeatedCursor"` | `"PaginationExceeded"` Stable discriminant for [McpToolsListError](#api-mcptoolslisterror) from paginated MCP `tools/list`. *** ### PendingReplace > **PendingReplace** = `Promise`\<[`ReplaceOutcome`](#api-replaceoutcome)> & [`ReplaceOutcome`](#api-replaceoutcome) What [SkillCatalog.replaceAll](#api-replaceall) returns: the [ReplaceOutcome](#api-replaceoutcome) counts, readable immediately, over a promise for the embedding pass. The corpus swap commits synchronously, so the counts are final the moment `replaceAll` returns and can be read before the embedding pass settles — a source that must report what a reload changed can therefore do so even when that pass fails. **Always `await` (or `.catch()`) the value, even when you only want the counts.** It is a real promise for the embedding pass, so leaving it unhandled turns an embedding failure into an unhandled rejection — which terminates the process under Node's default `--unhandled-rejections=throw`. Reading the counts is not a substitute for handling it: ```ts const reload = catalog.replaceAll(batch); // corpus live, counts final try { await reload; } catch { log.warn(`applied +${reload.added} -${reload.removed}, embeddings pending`); } ``` *** ### SearchMethod > **SearchMethod** = `"bm25"` | `"semantic"` | `"hybrid"` Retrieval engine for [ToolCatalog.search](#api-search-2) (and the skill catalog's `search`): * `"bm25"` — lexical ranking; model-free and infallible (the default). * `"semantic"` — cosine similarity over prebuilt embeddings. * `"hybrid"` — BM25 and semantic rankings fused with Reciprocal Rank Fusion (ADR-0011). `"semantic"`/`"hybrid"` embed inline during [ToolCatalog.register](#api-register-2); ranking against that cache needs `searchAsync()`. *** ### SearchOrigin > **SearchOrigin** = `"direct"` | `"agent"` Who initiated a search: `"direct"` for host code calling the SDK itself (pre-fetch helpers, benchmarks), `"agent"` for a call the model synthesized through the capability tools (`search_capabilities`). Recorded on trace events and the `ratel.origin` span attribute so consumers can separate the two paths. *** ### TraceSinkConfig > **TraceSinkConfig** = \{ `kind`: `"noop"`; } | \{ `kind`: `"memory"`; `sessionId`: `string`; } | \{ `kind`: `"jsonl"`; `path`: `string`; `sessionId`: `string`; } Where the local trace stream (ADR-0007) goes. Distinct from the OTel spans in [ToolCatalog](#api-toolcatalog)'s docs — this is the in-process channel drained via [ToolCatalog.drainTraceEvents](#api-draintraceevents-2) or written to disk. * `"noop"` — discard every event (the default when no `trace` option is given). * `"memory"` — buffer envelopes in-process; read them back with [ToolCatalog.drainTraceEvents](#api-draintraceevents-2). `sessionId` is stamped on each envelope. * `"jsonl"` — append one JSON envelope per line to the file at `path` (parent directories are created). `sessionId` is stamped on each envelope. #### Union Members ##### Type Literal \{ `kind`: `"noop"`; } | Name | Type | Description | | ------ | -------- | -------------------- | | `kind` | `"noop"` | Discard every event. | *** ##### Type Literal \{ `kind`: `"memory"`; `sessionId`: `string`; } | Name | Type | Description | | ----------- | ---------- | --------------------------------------------------- | | `kind` | `"memory"` | Buffer envelopes in-process for `drainTraceEvents`. | | `sessionId` | `string` | Session id stamped on every envelope. | *** ##### Type Literal \{ `kind`: `"jsonl"`; `path`: `string`; `sessionId`: `string`; } | Name | Type | Description | | ----------- | --------- | -------------------------------------------------- | | `kind` | `"jsonl"` | Append one JSON envelope per line to `path`. | | `path` | `string` | File to append to; parent directories are created. | | `sessionId` | `string` | Session id stamped on every envelope. | ## Variables ### ContentCapture > `const` **ContentCapture**: `object` Message/tool content capture modes for `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` (CONVENTIONS.md § Capture gating). Default off. #### Type Declaration | Name | Type | | --------------- | ------------------ | | `EventOnly` | `"EVENT_ONLY"` | | `NoContent` | `"NO_CONTENT"` | | `SpanAndEvent` | `"SPAN_AND_EVENT"` | | `SpanOnly` | `"SPAN_ONLY"` | *** ### GET\_SKILL\_CONTENT\_ID > `const` **GET\_SKILL\_CONTENT\_ID**: `"get_skill_content"` Wire id (`"get_skill_content"`) of the skill-loading capability tool built by [getSkillContentTool](#api-getskillcontenttool) — the name the model calls it by. *** ### INVOKE\_TOOL\_ERROR\_CAUSE > `const` **INVOKE\_TOOL\_ERROR\_CAUSE**: unique `symbol` Non-enumerable cause carried by a structured error from a target executor. *** ### INVOKE\_TOOL\_ID > `const` **INVOKE\_TOOL\_ID**: `"invoke_tool"` Wire id (`"invoke_tool"`) of the execution capability tool built by [invokeToolTool](#api-invoketooltool) — the name the model calls it by. *** ### SEARCH\_CAPABILITIES\_ID > `const` **SEARCH\_CAPABILITIES\_ID**: `"search_capabilities"` Wire id (`"search_capabilities"`) of the discovery capability tool built by [searchCapabilitiesTool](#api-searchcapabilitiestool) — the name the model calls it by. *** ### ~~SEARCH\_TOOLS\_ID~~ > `const` **SEARCH\_TOOLS\_ID**: `"search_tools"` #### Deprecated Use `SEARCH_CAPABILITIES_ID` (`"search_capabilities"`). ## Functions ### clearContentCapture() > **clearContentCapture**(`generation`): `void` Clear the programmatic content-capture override, but only when `generation` — the token returned by [setContentCapture](#api-setcontentcapture) — still identifies the most recent set. A stale token no-ops, so an old handle shutting down late cannot clobber an override a newer caller installed (and silently re-enable, or disable, capture via the env fallback). For an unconditional clear, use `setContentCapture(null)`. #### Parameters | Parameter | Type | | ------------ | -------- | | `generation` | `number` | #### Returns `void` *** ### formatUpstreamLine() > **formatUpstreamLine**(`s`): `string` Format one upstream server as the bullet line the capability-tool descriptions embed (`- — ( tools) (auth required)`, omitting the parts that are unset). Exported for the deprecated `searchToolsTool` shim, which builds the same listing. #### Parameters | Parameter | Type | Description | | --------- | ----------------------------------------------- | ----------------------- | | `s` | [`UpstreamServerInfo`](#api-upstreamserverinfo) | The server to describe. | #### Returns `string` A single `- `-prefixed line with the description compacted. *** ### getSkillContentTool() > **getSkillContentTool**(`catalog`): [`ExecutableTool`](#api-executabletool) Build the `get_skill_content` capability tool: load a skill's full instructions by id — the counterpart to `invoke_tool`. Skills are *read*, not executed: the agent discovers a skill in the `skills` bucket of `search_capabilities`, then pulls its playbook into context here. The tool takes `{ skillId }` and resolves to `{ body }` (the skill's Markdown) on success, or `{ error, isError: true }` for an unknown id — a structured result rather than a rejection, so the model can recover. Each load records a `ratel.skill.load` span plus a `skill_invoke` trace event (unknown ids record `gateway_error`). #### Parameters | Parameter | Type | Description | | --------- | ----------------------------------- | --------------------------------- | | `catalog` | [`SkillCatalog`](#api-skillcatalog) | Catalog whose skills this serves. | #### Returns [`ExecutableTool`](#api-executabletool) The tool, ready to expose to the model. *** ### invokeToolTool() > **invokeToolTool**(`catalog`, `opts?`): [`ExecutableTool`](#api-executabletool) Build the `invoke_tool` capability tool: the execution counterpart to [searchCapabilitiesTool](#api-searchcapabilitiestool). Takes `{ toolId, args }`, delegates input parsing to the selected catalog tool's live validator, then runs the prevalidated input through [ToolCatalog.invokeValidatedRaw](#api-invokevalidatedraw). That preserves a target `AsyncIterable` so framework adapters can expose preliminary outputs even when validation itself was asynchronous. Failures come back as structured `{ error, isError: true }` results, not rejections, so the model can read and recover from them: an unknown `toolId` (with a hint to search first), a non-object `args`, or a thrown executor error. Target executor errors also carry their original thrown value under the non-enumerable [INVOKE\_TOOL\_ERROR\_CAUSE](#api-invoke_tool_error_cause-1) marker so an adapter can restore its framework's native error lifecycle. A tool that throws `UnauthorizedError` gets special handling — `opts.onUnauthorized` fires with the upstream name inferred from the `__` id prefix, a `ratel.auth.flow` span records the outcome, and the result is `{ error: "needs_auth", isError: true, upstream?, hint }`. A call with `args` missing (or `null`) is tolerated by treating the remaining top-level keys as the arguments. The capability executor's optional opaque context is forwarded unchanged to the selected catalog executor; the core never reads or records it. Outcomes are recorded as `gateway_invoke` / `gateway_error` events on the local trace stream. #### Parameters | Parameter | Type | Description | | --------- | ----------------------------------------------------- | ---------------------------------- | | `catalog` | [`ToolCatalog`](#api-toolcatalog) | Catalog whose tools this executes. | | `opts` | [`InvokeToolToolOptions`](#api-invoketooltooloptions) | Optional auth-failure callback. | #### Returns [`ExecutableTool`](#api-executabletool) The tool, ready to expose to the model. *** ### isInvokeToolError() > **isInvokeToolError**(`value`): `value is InvokeToolError` Whether `value` is a structured error produced from a target executor failure. #### Parameters | Parameter | Type | | --------- | --------- | | `value` | `unknown` | #### Returns `value is InvokeToolError` *** ### ratel() > **ratel**(`config?`): [`Ratel`](#api-ratel) Create a framework-neutral Ratel core. It works standalone — register native tools on `r.tools`, skills on `r.skills`, hand the model `r.modelTools()`, rank with `r.recall(query)` — and [adapts](#api-adaptto) to a framework with a [RatelAdapter](#api-rateladapter) for that framework's native shapes. The core owns all state (the catalogs, the recall-id counter) and every framework-independent guard — reserved capability-tool ids, top-K clamping, first-registration-wins on the adapted path, passthrough of non-executable tools — so adapters stay tiny. One core can back several adapter views (they share the catalog, embeddings, and counter). #### Parameters | Parameter | Type | Description | | --------- | --------------------------------- | --------------------------------------------------------------------------------------- | | `config` | [`RatelConfig`](#api-ratelconfig) | Retrieval method, embedding model (for semantic/hybrid), recall budget, and trace sink. | #### Returns [`Ratel`](#api-ratel) The standalone core; call `.adaptTo(adapter())` for a framework-shaped view. #### Example ```ts import { ratel } from "@ratel-ai/sdk"; import { aiSdk } from "@ratel-ai/vercel-ai-sdk"; const r = ratel({ recallTopK: 5 }).adaptTo(aiSdk()); await r.tools.register(myTools); const tools = r.modelTools(); // stable capability set for the model — take once, reuse const messages = await r.appendRecall(history); // per-turn recall (AI SDK idiom) ``` *** ### registerMcpServer() > **registerMcpServer**(`catalog`, `options`): `Promise`\<[`McpServerHandle`](#api-mcpserverhandle)> Ingest an MCP server into a [ToolCatalog](#api-toolcatalog): connect over the given transport, list every paginated `tools/list` page (no live refresh), and register each as an [executable tool](#api-register-2) whose executor proxies `callTool` on the live client. A missing tool description registers as `""`; a missing output schema as `{ type: "object" }`. The whole registration is one `ratel.upstream.register` OTel span and an `upstream_register` local trace event; each later invocation records `upstream_invoke` (or `upstream_error`) alongside the catalog's own events (ADR-0007). Rejects if connecting or listing tools fails. #### Parameters | Parameter | Type | Description | | --------- | ----------------------------------------------------------- | --------------------------------------------- | | `catalog` | [`ToolCatalog`](#api-toolcatalog) | Catalog that receives the proxied tools. | | `options` | [`RegisterMcpServerOptions`](#api-registermcpserveroptions) | Server name (the id namespace) and transport. | #### Returns `Promise`\<[`McpServerHandle`](#api-mcpserverhandle)> A handle with the registered ids, the server's instructions, and `close()`. #### Example ```ts import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { registerMcpServer, ToolCatalog } from "@ratel-ai/sdk"; const catalog = new ToolCatalog(); const github = await registerMcpServer(catalog, { name: "github", transport: new StdioClientTransport({ command: "github-mcp-server" }), }); // github.toolIds → ["github__create_issue", "github__get_pull_request", ...] const result = await catalog.invoke("github__create_issue", { title: "Flaky test on main", }); await github.close(); ``` *** ### runCapabilitiesSearch() > **runCapabilitiesSearch**(`toolCatalog`, `query`, `opts?`): `Promise`\<[`SearchCapabilitiesResult`](#api-searchcapabilitiesresult)> Rank a query into the `search_capabilities` result shape — the single source of truth for that shape, shared by [searchCapabilitiesTool](#api-searchcapabilitiestool) (agent origin) and both `ratel()` recall paths, standalone core and adapted views (direct origin), so they can never drift. Ranks the `tools` bucket (grouped by upstream server, a matched skill's declared tool deps pulled in additively at score `0`, deduped) and the independently-budgeted `skills` bucket, caps both top-Ks at 50 (invalid values fall back to their defaults), and records one `gateway_search` event with the given `origin`. #### Parameters | Parameter | Type | Description | | ------------- | ------------------------------------------------------------- | ------------------------------------------------------------ | | `toolCatalog` | [`ToolCatalog`](#api-toolcatalog) | Catalog the `tools` bucket is ranked from. | | `query` | `string` | Natural-language description of what the caller wants to do. | | `opts` | [`CapabilitiesSearchOptions`](#api-capabilitiessearchoptions) | Bucket sizes, skill catalog, origin, and upstream metadata. | #### Returns `Promise`\<[`SearchCapabilitiesResult`](#api-searchcapabilitiesresult)> A promise for the [SearchCapabilitiesResult](#api-searchcapabilitiesresult). *** ### searchCapabilitiesTool() > **searchCapabilitiesTool**(`toolCatalog`, `skillCatalog?`, `opts?`): [`ExecutableTool`](#api-executabletool) Build the `search_capabilities` capability tool: unified discovery over tools AND skills. Its result carries two independently-ranked buckets, each with its own top-K budget — so a relevant skill can never be starved out of the results by a large number of matching tools (and we avoid comparing BM25 scores across the two different text shapes). The tool takes `{ query, topKTools?, topKSkills? }` (defaults 5 and 3; values above 50 are capped, anything else non-positive or non-integer falls back to the default) and resolves to a [SearchCapabilitiesResult](#api-searchcapabilitiesresult). A matched skill's declared tool dependencies are pulled into the `tools` bucket additively — score `0`, beyond the `topKTools` budget, deduped against query hits. The skills clause of the tool description appears when `skillCatalog` is non-empty at build time (override with [SearchCapabilitiesOptions.advertiseSkills](#api-advertiseskills)); the result's `skills` bucket always ranks the live catalog. Each call records a `gateway_search` event on the local trace stream. #### Parameters | Parameter | Type | Description | | --------------- | ------------------------------------------------------------- | --------------------------------------------------------------- | | `toolCatalog` | [`ToolCatalog`](#api-toolcatalog) | Catalog the `tools` bucket is ranked from. | | `skillCatalog?` | [`SkillCatalog`](#api-skillcatalog) | Optional catalog the `skills` bucket is ranked from. | | `opts?` | [`SearchCapabilitiesOptions`](#api-searchcapabilitiesoptions) | Upstream-server metadata for the description and result groups. | #### Returns [`ExecutableTool`](#api-executabletool) The tool, ready to expose to the model (and to register alongside [invokeToolTool](#api-invoketooltool), which executes what this discovers). #### Example ```ts import { searchCapabilitiesTool, type SearchCapabilitiesResult } from "@ratel-ai/sdk"; const discovery = searchCapabilitiesTool(toolCatalog, skillCatalog, { upstreamServers: [{ name: "github", description: "GitHub API", toolCount: 30 }], }); const result = (await discovery.execute({ query: "open a pull request", })) as SearchCapabilitiesResult; ``` *** ### ~~searchToolsTool()~~ > **searchToolsTool**(`catalog`, `opts?`): [`ExecutableTool`](#api-executabletool) The pre-0.2.0 tools-only discovery tool, preserved verbatim (id `search_tools`, `{ groups }` result, `topK` input). New code should use [searchCapabilitiesTool](#api-searchcapabilitiestool), which additionally returns a reserved `skills` bucket. Registering both lets a host serve the old and new names during a migration window. #### Parameters | Parameter | Type | | --------- | ------------------------------------------------------- | | `catalog` | [`ToolCatalog`](#api-toolcatalog) | | `opts?` | [`SearchToolsToolOptions`](#api-searchtoolstooloptions) | #### Returns [`ExecutableTool`](#api-executabletool) #### Deprecated Use `searchCapabilitiesTool`. Tracked for removal in RAT-250. *** ### setContentCapture() > **setContentCapture**(`mode`): `number` Programmatically set the content-capture mode. While set, [contentCaptureMode](https://github.com/ratel-ai/ratel/blob/main/src/telemetry/ts/src/content-capture.ts) returns this mode regardless of `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` — programmatic config wins over the environment, matching how OpenTelemetry treats env vars as the fallback for code-level configuration. Pass `null`/`undefined` to clear the override unconditionally and return to env-driven parsing. The mode is validated like the env var (case-insensitive, legacy `true`/`false`/ `1`/`0` accepted) and **throws a `TypeError`** on anything unrecognized — failing loud at config time instead of storing a value that would both disable capture and mask the env var. Returns a generation token identifying this call as the current owner of the override; pass it to [clearContentCapture](#api-clearcontentcapture) to clear only if no newer set has happened since (the safe form for shutdown/teardown hooks). #### Parameters | Parameter | Type | | --------- | ---------------------------------------------------------------- | | `mode` | [`ContentCapture`](#api-contentcapture) \| `null` \| `undefined` | #### Returns `number` --- # Changelog Source: https://www.ratel.sh/docs/packages/typescript/sdk/changelog > @ratel-ai/sdk release history · [npm](https://www.npmjs.com/package/@ratel-ai/sdk) · [source](https://github.com/ratel-ai/ratel/tree/main/src/sdk/ts) > > Synced verbatim from the package's `CHANGELOG.md` at `ratel-ai/ratel@d8635d5` — do not edit by hand. Regenerate with `pnpm sync:api` in `apps/docs`. All notable changes to `@ratel-ai/sdk` are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this package adheres to [Semantic Versioning](https://semver.org/). ## \[0.7.0] - 2026-08-07 > **On npm's `rc` channel?** The `0.6.1-rc.*` and `0.7.0-rc.0` prereleases on npm were cut > off experimental branches that never landed, so they are **not** ancestors of this > release. `0.7.0` does not contain their seed-first intent-graph APIs > (`experimentalBuildIntentGraph`, `experimentalBaselineTurn`, the `"callback"` trace sink) > or `experimentalExposePassthrough`; moving from `rc` to `latest` drops them. Upgrades > from `0.6.0` are unaffected — for them this release is purely additive. ### Added * **Whole-catalog skill reload: `SkillCatalog.replaceAll` (ADR-0015).** For a source that fetches the full skill catalog rather than individual changes — the batch *is* the catalog, so ids missing from it are removed, including ones registered in-process (a host mixing local and remote skills composes the batch itself). It mutates in place, so the one `SkillCatalog` behind `r.skills`, every adapted view, and every capability tool from `modelTools()` all see the reload. Two-phase like `register`: the corpus swap commits synchronously and the embedding pass is the awaitable, so a reload whose embedding pass fails still reports what the swap changed — `replaceAll` returns a `PendingReplace` carrying the `ReplaceOutcome` counts (added / removed / updated / unchanged) and awaitable separately. On embedding failure the new corpus is live and BM25 ranks it while semantic search reports `EmbeddingsNotBuilt` until a later pass succeeds; a reload started while a dense operation owns the registry is rejected rather than blended. Reloading an unchanged catalog costs zero embeddings, and `advertiseSkills` already pins the `search_capabilities` description, so a reload can't bust the prompt cache. `PendingReplace` and `ReplaceOutcome` are exported. * **`registerMcpServer` follows every `tools/list` page.** Ingestion previously read only the first page, silently dropping every tool past it on a paginated server. It now walks `nextCursor` to exhaustion (treating `""` as a valid cursor, per MCP — only an absent `nextCursor` ends pagination) across both `mcp` 1.x and 2.x clients, capped at 64 pages. `McpToolsListError` (with a stable `code` of `"RepeatedCursor"` or `"PaginationExceeded"`) and the `McpToolsListErrorCode` type are exported, so a cursor loop or a runaway server is a typed failure rather than a hang. `McpServerHandle.toolIds` now spans all pages in upstream list order. ### Fixed * `registerMcpServer` closes the MCP client when connecting, listing, or catalog registration throws. It previously leaked the live connection on any failure after `connect`. ## \[0.6.0] - 2026-07-28 > **Coming from `0.6.0-rc.0`?** That RC was tagged off a branch that predated 0.5.3, so it > still shipped `configureTelemetry()` and pinned `@ratel-ai/telemetry@^0.1.2`. Read the > 0.5.3 entry below as part of this upgrade — its breaking changes land for you here. > Upgrades from 0.5.3 are unaffected: for them this release is purely additive. ### Added * **Experimental adaptive usage ranking (ADR-0014).** `IntentGraph` plus `experimentalEnableAdaptiveRanking`, `experimentalRebuildIntentGraph`, `experimentalDisableAdaptiveRanking`, and `experimentalAdaptiveRankingStatus` on `ToolCatalog` / `SkillCatalog` (and the underlying registries). The catalog learns from each search-then-invoke and boosts future rankings; persist and reload the learning via `IntentGraph.toJson` / `fromJson`, and track writes via `graph.rev`. Shipped behind an `experimental` prefix — the API may change until it graduates. * `rank` and `fused` on search hits: order on `rank`, and branch on `fused` to know whether the usage arm changed the ranking. * Opt-in recovery after an embedding-model change: `experimentalEnableAdaptiveRanking(graph, { rebuildOnModelChange: true })` re-embeds a paused graph on the next dense search. Default off; explicit `experimentalRebuildIntentGraph()` otherwise. `experimentalAdaptiveRankingStatus` reports the paused/active state and the mismatched model detail. ## \[0.5.3] - 2026-07-26 > **Read this before upgrading from 0.5.2.** Despite the patch version, this release > **removes** `configureTelemetry()`. The number is patch because 0.5.3 was already cut > in the repo before the removal landed and the two shipped together; treat the upgrade > as breaking if you called that function. Everything else is additive. ### Added * Content capture emits structured OpenTelemetry Logs `EventRecord`s. `EVENT_ONLY` and `SPAN_AND_EVENT` now produce real records (in 0.5.2 the event half of those modes emitted nothing), carrying `gen_ai.system_instructions`, `gen_ai.input_messages`, `gen_ai.output_messages`, and `ratel.tool.execution_details`. Tool results stay out of inference-output messages, which are reserved for model output with a `finish_reason`. ### Changed * **BREAKING:** the telemetry bootstrap `configureTelemetry()` is gone, with its `TelemetryHandle`, `InitOptions`, and `ConfigureTelemetryOptions` types. The SDK ships no OpenTelemetry provider wiring — it emits `ratel.*`/`gen_ai.*` to whatever providers the host has registered, and the host owns the provider (`new NodeSDK({ spanProcessors })`), its composition, and its flush/shutdown. The content-capture gate (`ContentCapture`, `setContentCapture`, `clearContentCapture`) and the emitted spans and `EventRecord`s are unchanged. To migrate, build the provider yourself and register it before importing the SDK; see the [package README](https://github.com/ratel-ai/ratel/blob/main/src/sdk/ts/README.md). * **BREAKING:** the optional `@ratel-ai/telemetry-otlp` peer dependency is gone, along with the `require()`-an-ES-module machinery that loaded it. `@ratel-ai/telemetry-otlp` is discontinued, and 0.4.0's `ratelSpanProcessor` recipe with it: build the OTLP exporter onto the provider you register. * `engines.node` is now `>=20.6.0` (0.5.2 declared `>=20.0.0`), matching the `@ratel-ai/telemetry` runtime dependency. * New runtime dependency `@opentelemetry/api-logs` (`^0.220.0`), the API-only Logs surface the `EventRecord` emission needs. Like `@opentelemetry/api` it is inert until the host registers a provider. * The `@ratel-ai/telemetry` dependency floor moves to `^0.2.0`, the vocabulary version that defines the `EventRecord` contract. ## \[0.5.2] - 2026-07-24 ### Changed * Ship the native addon built with symbol stripping and thin LTO (`[profile.release]`), shrinking the `.node` binary \~26% (9.90 MB → 7.36 MB on the sdk-ts cdylib). No API or behavior change. ## \[0.5.1] - 2026-07-23 ### Added * **Framework-adapter SPI + `ratel()` factory (ADR-0013).** `ratel(config)` is a standalone, framework-free core: `r.tools` is a handle over its one shared `ToolCatalog` (register native `ExecutableTool`s any time — also after exposure, since the capability tools search the live catalog), `r.skills` the shared `SkillCatalog`, `modelTools()` returns the three capability tools (always all three, so the set never depends on registration order), and `recall(query)` is an async pure query resolving to the canonical `search_capabilities` result or `null`. `adaptTo(adapter)` layers a framework-shaped view over the same state: `tools.register(...)` ingests framework tools (first registration of an id wins across views; provider-run tools pass through per view), `modelTools()` returns the model-facing set in framework shape, `recall(query)` resolves to the synthetic message pair with a call id from the core's private counter (never a transcript position). `RatelConfig` forwards `method` and `embedding` to both catalogs, so a `"semantic"`/`"hybrid"` core is fully configurable through the factory (ADR-0012's models). `r.tools.register(...)` is async: it validates synchronously (a missing `execute`, a reserved id, or a framework-shaped tool throws at the call site) and returns a promise that resolves once the batch is indexed and — on a semantic/hybrid core — embedded, rejecting if embedding fails, so errors surface at registration (`await r.tools.register(...)` before searching a dense core). The handle's `search(...)` is synchronous BM25-only (a dense method points to `searchAsync` instead of leaking the native error); `searchAsync(...)` ranks any method off the event loop. Types are inferred from the adapter (`AdaptedRatel`), so app code needs no casts. A `RatelAdapter` is three codecs — `ingest` (framework tool → catalog registration, or `"passthrough"`), `expose` (capability tool → framework tool), `recallMessages` (synthetic `search_capabilities` pair) — plus an optional `extend` for framework idioms; the framework packages (`@ratel-ai/vercel-ai-sdk`, `@ratel-ai/mastra`) ship separately. Guards are core-owned: reserved capability-tool ids throw on registration, recall top-K is capped at 50 (invalid values fall back to the default 5), and a framework-shaped tool on the native path throws an actionable install-the-adapter error, probing known frameworks via `isPeerInstalled` (message only). The existing piecemeal API (`ToolCatalog`, capability-tool builders) is unchanged, except one additive option: `SearchCapabilitiesOptions.advertiseSkills` pins the skills clause of the `search_capabilities` description on or off (the size-gated default is untouched); `modelTools()` uses it so the exposed payload is byte-identical whether skills register before or after it is taken. * `runCapabilitiesSearch(toolCatalog, query, opts)` — the exported single source of truth for the `search_capabilities` result shape, shared by `searchCapabilitiesTool` (origin `agent`) and the host-driven recall path (origin `direct`). Async, matching the catalog's `searchAsync` retrieval. `JSONSchema7` is re-exported as the SDK's public JSON-Schema spelling so adapters type their registrations without casts. * **Adapter conformance testkit (`@ratel-ai/sdk/testkit`).** A runner-agnostic battery every framework adapter must pass, pinning the whole SPI contract: ingest/expose round-trip, the reserved-id guard, recall top-K clamp, passthrough semantics, and recall-pair shape (validated through framework-supplied hooks). `adapterConformanceCases(options)` returns named cases (assertions via `node:assert`, so no test-runner dependency leaks into shipped types); `describeAdapterConformance(options, { describe, it })` registers them as first-class tests under Vitest/Jest/`node:test`. Ships `referenceAdapter`/`referenceConformanceOptions` as the worked example a real adapter's options copy. Reached via a new `exports` map whose `.` entry is byte-identical to today's `main`/`types`; the map also seals hypothetical deep imports of package internals. * Framework adapters can attach an `InputValidator` to `CatalogRegistration` / `ExecutableTool`; the shared `ToolCatalog` keeps that parser authoritative across adapted views and native hot-swaps. `validateInput()` exposes the live parser, and `invokeValidatedRaw()` preserves a prevalidated executor's immediate scalar, promise, or `AsyncIterable` shape. `invokeRaw()` provides the same preservation after synchronous validation, while `invoke()` remains the Promise-based public convenience path. * `invoke_tool` target failures remain structured for generic hosts and now carry their original cause under a non-enumerable symbol for framework adapters. ### Changed * Add an optional opaque invocation context to `Executor`, `CatalogRegistration.execute`, and `ToolCatalog.invoke`, and forward it unchanged through `invokeToolTool`. Framework adapters can now preserve request-scoped execution state without the core inspecting, storing, or tracing it; existing one-argument executors retain their source compatibility and runtime call arity. ### Fixed * `invoke_tool` no longer collapses streamed tool results into an opaque object; local trace events and `execute_tool` spans now settle when iteration completes, is cancelled, or fails, including cancellation-cleanup failures. ## \[0.5.0] - 2026-07-20 ### Added * `register()` accepts a single item or an array across tool/skill registries and catalogs. * Configurable default, HuggingFace, local Candle, Ollama, and OpenAI-compatible endpoint embedding sources, with public `EmbeddingSpec` and `EmbeddingModelConfig` types. * Typed embedding errors: `EmbedderError` (with a stable `code`) and its `DimensionMismatchError` subclass are thrown from `register()`/`searchAsync()` on a semantic/hybrid catalog, so callers can branch on `instanceof`/`code` instead of matching message text — parity with the Python SDK. Invalid embedding config still throws at construction. ### Changed * **BREAKING:** `register()` now returns a promise and accepts a single tool/skill **or an array of them**, and folds embedding in: on a `"semantic"`/`"hybrid"` catalog it embeds the batch on a libuv worker (never blocking the event loop), so embedding errors (model load / endpoint / auth / dimension) surface from `await register(...)`. A `"bm25"` catalog registers metadata only and never loads a model. `search()` stays synchronous BM25-only; `searchAsync()` covers BM25/semantic/hybrid. There is **no** `registerMany()`, `buildEmbeddings()`, or `rebuildEmbeddings()` — `register()` embeds, and recovery from a model/dimension change is to construct a new catalog and re-register. * Capability tools await async retrieval; MCP ingestion embeds ingested tools during `register`. * Embedding configuration is validated and retained on BM25-default catalogs for later async semantic/hybrid overrides; source unions are mutually exclusive. ### Fixed * A `"semantic"`/`"hybrid"` `searchAsync()` whose corpus was never embedded (the signature of a forgotten `await register(...)`) now reports an actionable "did you await register()?" hint, not just the bare "embeddings not computed" message. ## \[0.4.1] - 2026-07-10 ### Added * `configureTelemetry` opts into message/tool content capture programmatically: `captureContent` sets the exact `ContentCapture` mode (validated like the env var — case-insensitive, legacy boolean forms accepted — throwing a `TypeError` on garbage before any exporter is wired), and `includeSpanAndEvents` is boolean sugar (`true` → `SPAN_AND_EVENT`, `false` → `NO_CONTENT`). `captureContent` wins over `includeSpanAndEvents`; when neither is provided, `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` keeps ruling (a provided option beats the env var, as in OTel code-over-env precedence). The handle's `shutdown()` restores env-driven behavior via a generation-scoped clear (`clearContentCapture`), so a stale handle shutting down late never clobbers an override a newer `configureTelemetry` installed. `ContentCapture`, `setContentCapture`, and `clearContentCapture` are re-exported from `@ratel-ai/sdk` (new `ConfigureTelemetryOptions` type). ## \[0.4.0] - 2026-07-07 ### Added * **OpenTelemetry emission.** The SDK now opens an OTel span at each funnel boundary — `execute_tool` (`gen_ai.operation.name`, `gen_ai.tool.name`, `ratel.tool.args_size_bytes`, plus `ratel.upstream.server` for MCP-proxied tools), `ratel.search` (target, `top_k`, origin, `hit_count`), `ratel.skill.load`, `ratel.upstream.register`, and `ratel.auth.flow` — alongside the existing local `recordEvent` stream, which is unchanged. Emission is transparent and free by default: spans flow to whatever OpenTelemetry provider is registered and are a no-op until one is, so a host already running OTel sees Ratel's funnel on its traces with no setup. Built on `@opentelemetry/api` + the OTel-free `@ratel-ai/telemetry` vocabulary, so the base install stays OTel-SDK-free. * `configureTelemetry({ apiKey })` convenience wiring (with `TelemetryHandle` / `InitOptions`): lazily loads the optional `@ratel-ai/telemetry-otlp` peer to ship the SDK's spans to Ratel Cloud (or any OTLP endpoint). Hosts already running OpenTelemetry skip it and add `ratelSpanProcessor` from `@ratel-ai/telemetry-otlp` instead. * Message/tool content (`ratel.search.query`, `gen_ai.tool.call.arguments` / `.result`) rides span attributes only when `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` selects a span mode (`SPAN_ONLY` / `SPAN_AND_EVENT`); default off. `ratel.tool.args_size_bytes` is measured in UTF-8 bytes. ## \[0.3.0] - 2026-07-06 ### Added * `ToolCatalog` / `SkillCatalog` accept a default `method` (`"bm25"` | `"semantic"` | `"hybrid"`) and `search(query, topK, origin?, method?)` takes a per-call override. `"bm25"` (default) is unchanged and model-free; `"semantic"` / `"hybrid"` load a local embedding model and throw if it fails to load. Exposed via the native `searchWithMethod` binding and the `SearchMethod` type. * A `"semantic"`/`"hybrid"` catalog embeds each tool/skill **eagerly at `register`** (incrementally), so searches never pay the corpus-embedding cost. New `catalog.buildEmbeddings()` pre-computes embeddings on demand (e.g. after a bulk register). BM25 catalogs do neither. A semantic/hybrid search on a catalog with no embeddings built throws (embeddings not computed) rather than embedding during the search. ## \[0.2.1-rc.1] - 2026-07-04 ### Changed * First release cut under the per-package release scheme (ADR-0008): `@ratel-ai/sdk` (loader + platform packages) now versions and ships independently, tagged `sdk-js-v*`. No API changes since 0.2.0. ## \[0.2.0] - 2026-06-16 ### Changed * **BREAKING:** the discovery gateway tool is renamed `search_tools` → `search_capabilities`. It now returns two independently-ranked, separately-budgeted buckets — `{ tools, skills }` — so a relevant skill is never crowded out by matching tools. New surface: `searchCapabilitiesTool`/`SEARCH_CAPABILITIES_ID`. The old `searchToolsTool`/`SEARCH_TOOLS_ID` are kept as deprecated aliases (see *Deprecated*), so `0.1.x` code keeps working after upgrading. ### Added * First-class **skills**: `SkillCatalog`, `getSkillContentTool` (`get_skill_content`), and `Skill`/`SkillHit`/`SkillRegistry`. Skills are reusable playbooks ranked by a separate BM25 corpus and loaded on demand. * Skill–tool coupling: a `Skill` can declare a `tools` list, and `search_capabilities` pulls a matched skill's declared tools into the `tools` bucket — additively (beyond `topKTools`) and deduped against query hits — so the agent gets the playbook and the tools it needs in one turn. ### Deprecated * `searchToolsTool`, `SEARCH_TOOLS_ID`, and the `SearchToolHit`/`SearchToolsGroup`/`SearchToolsResult`/`SearchToolsToolOptions` types. They retain their pre-0.2.0 behaviour — a tools-only `{ groups }` result and the `search_tools` id — so upgrading from `0.1.x` does not break existing callers. Migrate to `searchCapabilitiesTool`; the aliases will be removed in a future release. ### Fixed * Gateway error payloads (`invoke_tool`, `get_skill_content`) carry `isError: true`, so a host can flag a failed call rather than read it as content. * `invoke_tool` rejects a non-object `args` instead of forwarding stray top-level keys. * `search_capabilities` validates `topKTools`/`topKSkills` (declared `integer`, positive): `0`, negative, and fractional values fall back to the default instead of returning zero results — or, via a negative wrapping to `u32` in the native layer, an unbounded set. TypeScript and Python behave identically. * `search_capabilities` advertises the `skills` bucket and `get_skill_content` in its description only when a non-empty skill catalog is wired in. * `Skill.tags` and `Skill.body` are optional (default `[]`/`""`), matching the Python SDK — a minimal `{ id, name, description }` skill is valid. ## \[0.1.6] - 2026-06-10 ### Fixed * TypeScript typehint for JSON-schema tool input/output ([#54](https://github.com/ratel-ai/ratel/pull/54)). ## \[0.1.5] - 2026-05-10 ### Added * Initial release on the v1 (revamp) line. TypeScript SDK over the Rust core: BM25 tool retrieval, MCP ingestion, framework-neutral gateway tools. See the [package README](https://github.com/ratel-ai/ratel/blob/main/src/sdk/ts/README.md) for the full surface. * `ToolCatalog` accepts `{ trace }` config in its constructor — `noop` (default), `memory`, or `jsonl`. Captured events flow through the Rust core sink ([ADR-0007](https://github.com/ratel-ai/ratel/blob/main/docs/adr/0007-telemetry-two-streams.md)). New `recordEvent`, `drainTraceEvents`, and an optional third `origin` argument on `search`. * `searchToolsTool` emits `gateway_search` with `origin: "agent"`. `invokeToolTool` emits `gateway_invoke` on success and `gateway_error` for unknown ids, `needs_auth`, and underlying throws. * `ToolCatalog.invoke` emits `invoke_start` / `invoke_end` / `invoke_error` around the executor with `args_size_bytes` and `took_ms`. * `registerMcpServer` emits `upstream_register` on connect and `upstream_invoke` / `upstream_error` per upstream call. New `searchWithOrigin` and trace plumbing on the underlying NAPI `ToolRegistry`. `SearchOrigin` is now `"direct" | "agent"` (was `"user" | "agent"`); the union is exported from the package entry point. ### Changed * Rewrote `search_tools` description to nudge agents toward discovery before falling back to broad listing. --- # @ratel-ai/vercel-ai-sdk Source: https://www.ratel.sh/docs/packages/typescript/vercel-ai-sdk > v0.3.0 · TypeScript · [npm](https://www.npmjs.com/package/@ratel-ai/vercel-ai-sdk) · [source](https://github.com/ratel-ai/ratel/tree/main/src/adapters/ts-vercel-ai-sdk) > > Generated from the doc comments at `ratel-ai/ratel@d8635d5` — do not edit by hand. Regenerate with `pnpm sync:api` in `apps/docs`. `@ratel-ai/vercel-ai-sdk` — the Vercel AI SDK adapter for Ratel. Implements the `@ratel-ai/sdk` framework-adapter SPI (ADR-0013) for `ai@^5 || ^6 || ^7`, so `ratel(config).adaptTo(aiSdk())` speaks the AI SDK's native `Tool` and `ModelMessage` shapes and adds the SDK-idiomatic per-turn recall helpers. The `ai@7` telemetry integration lives at `@ratel-ai/vercel-ai-sdk/otel`, not here: it needs `@ai-sdk/otel`, which pins its own `ai@7`. Re-exporting it from this entrypoint would drag that second `ai` copy into every `ai@5`/`ai@6` host's type graph. ## Interfaces ### AiSdkExt The AI SDK-idiomatic per-turn recall helpers [aiSdk](#api-aisdk) merges onto the adapted view via the SPI's `extend` hook. Two ways to inject the same synthetic `search_capabilities` pair; pick one per host (see the package README's cache trade-off). #### Methods ##### appendRecall() > **appendRecall**(`messages`): `Promise`\<`ModelMessage`\[]> Rank the catalog against the last user message and append the synthetic `search_capabilities` pair at the transcript suffix (recall mode), then return `messages`. Mutates and returns the same array so prior turns' recalls stay in the history — a suffix append extends the cached prefix instead of busting it. Async (core recall is async). A no-op that returns `messages` untouched and spends no call id unless the last message is a user turn with text and there are hits. Hosts that rebuild the array per request must persist the appended pair themselves. ###### Parameters | Parameter | Type | | ---------- | ----------------- | | `messages` | `ModelMessage`\[] | ###### Returns `Promise`\<`ModelMessage`\[]> ##### prepareStep() > **prepareStep**(`options`): `Promise`\<\{ `messages`: `ModelMessage`\[]; } | `undefined`> A `prepareStep` for `generateText` / `streamText` / an Agent: on step 0 with a user turn and hits, append a cached recall pair to a fresh messages array. On later steps, return `undefined` when the host already carried the pair forward (ai\@7), or reinsert the cached pair at its original boundary when the host rebuilt the prompt without it (ai\@5/6). Never mutates the caller's messages or repeats recall within one run. Structurally assignable to `PrepareStepFunction` for any `TOOLS`; direct step-0 calls may omit `steps` when no multi-step carry-forward is needed. ###### Parameters | Parameter | Type | | -------------------- | -------------------------------------------------------------------------------------------- | | `options` | \{ `messages`: `ModelMessage`\[]; `stepNumber`: `number`; `steps?`: readonly `unknown`\[]; } | | `options.messages` | `ModelMessage`\[] | | `options.stepNumber` | `number` | | `options.steps?` | readonly `unknown`\[] | ###### Returns `Promise`\<\{ `messages`: `ModelMessage`\[]; } | `undefined`> ## Functions ### aiSdk() > **aiSdk**(): `RatelAdapter`\<`Tool`, `ModelMessage`, [`AiSdkExt`](#api-aisdkext)> The Vercel AI SDK adapter: `ratel(config).adaptTo(aiSdk())` gives the framework-neutral core the AI SDK's native Tool and ModelMessage shapes, across `ai@5`, `ai@6`, and `ai@7`. The core owns every guard (reserved ids, top-K clamp, first-registration-wins, recall-id counter), so the adapter is just the three codecs — `ingest` / `expose` / `recallMessages` — plus the [AiSdkExt](#api-aisdkext) recall helpers. #### Returns `RatelAdapter`\<`Tool`, `ModelMessage`, [`AiSdkExt`](#api-aisdkext)> A RatelAdapter over the AI SDK's tool and message types. --- # Changelog Source: https://www.ratel.sh/docs/packages/typescript/vercel-ai-sdk/changelog > @ratel-ai/vercel-ai-sdk release history · [npm](https://www.npmjs.com/package/@ratel-ai/vercel-ai-sdk) · [source](https://github.com/ratel-ai/ratel/tree/main/src/adapters/ts-vercel-ai-sdk) > > Synced verbatim from the package's `CHANGELOG.md` at `ratel-ai/ratel@d8635d5` — do not edit by hand. Regenerate with `pnpm sync:api` in `apps/docs`. All notable changes to `@ratel-ai/vercel-ai-sdk` are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this package adheres to [Semantic Versioning](https://semver.org/). ## \[0.3.0] - 2026-07-28 ### Changed * The `@ratel-ai/sdk` peer range moves to `^0.6.0`, tracking the SDK release that adds experimental adaptive usage ranking. The adapter's own API is unchanged, and 0.6.0 is additive over 0.5.3 — the range moves because at 0.x a caret does not span minors, so `^0.5.3` excludes `0.6.x`. npm resolves peers automatically, which makes that mismatch an install-time `ERESOLVE` rather than a warning: `@ratel-ai/vercel-ai-sdk@0.2.0` cannot be installed alongside `@ratel-ai/sdk@0.6.x`, and this release cannot be installed alongside `@ratel-ai/sdk@0.5.x`. Staying on the 0.5 SDK means staying on adapter 0.2.0. ## \[0.2.0] - 2026-07-26 ### Added * `RatelOtelIntegration`, an `ai@7` telemetry integration, at the new `@ratel-ai/vercel-ai-sdk/otel` entrypoint. It embeds `@ai-sdk/otel`'s `OpenTelemetry` emitter as a private delegate and stamps Ratel's `ratel.origin` overlay on every span through that emitter's `enrichSpan` hook, so hosts get the AI SDK's standard `gen_ai.*` spans plus the `ratel.*` overlay. It only *creates* spans, onto a provider the host owns — it never registers a provider and never exports, so any processor already on that provider (Langfuse, a generic OTLP exporter, anything else) receives them. Its options are `@ai-sdk/otel`'s `OpenTelemetryOptions` plus `origin`, which picks the `ratel.origin` value and defaults to `agent` — right for the tool-loop spans an agent synthesizes, wrong for host-driven `embed` / `embedMany` / `rerank`, so the value is the host's to set. An `enrichSpan` of the host's own is called too and its attributes merged *under* Ratel's: every host attribute lands except `ratel.origin` itself, which the overlay keeps. A host hook that throws costs the host its own attributes for that span and nothing more — the emitter's own guard discards the whole return value, so the integration guards the host call separately to keep `ratel.origin` unconditional. `Origin` is re-exported from the entrypoint, since `@ratel-ai/telemetry` is the adapter's dependency rather than the host's. Register exactly one emitting integration: this one, Langfuse's, and the bare `OpenTelemetry` all embed the same emitter, so two would duplicate every `gen_ai.*` span. On `ai@5`/`ai@6` there is no integration seam — pass `experimental_telemetry: { isEnabled: true }` per call instead. * `@ai-sdk/otel` and `@opentelemetry/api` as **optional** peers, needed only by `./otel`. Optional and off-root are both load-bearing, not stylistic: `@ai-sdk/otel` depends on an exact `ai@7`, so a required peer or a root re-export drags a second `ai` into an `ai@5`/`ai@6` host's type graph, where the two copies redeclare `AI_SDK_DEFAULT_PROVIDER` and break the host's build (TS2403) without it ever importing the integration. The compat matrix now asserts a packed consumer resolves no `@ai-sdk/otel`; its v7 rows typecheck `RatelOtelIntegration` against the real `ai@7` `Telemetry` interface and then actually import and construct it. ### Changed * The adapter takes its first runtime dependency, `@ratel-ai/telemetry` — the zero-dependency `ratel.*` constants package that `dist/otel.js` imports unconditionally. A peer could go unmet or unhoisted and break that import at load time with `ERR_MODULE_NOT_FOUND`, which no typecheck would catch: the `ratel.*` constants never reach `otel.d.ts`. It carries no `ai` of its own, so none of the duplicate-`ai` hazard that keeps the other two peers optional applies. * Dev-pinned `ai` moves to `7.0.37` (matching `@ai-sdk/otel@1.0.37`, which depends on that exact release), and the compat matrix's latest v7 row moves with it. The peer range is unchanged. * The `@ratel-ai/sdk` peer range moves to `^0.5.3`, the release that removed `configureTelemetry()` and moved telemetry provider ownership to the host. The adapter's own API is unchanged; the floor moves because the `./otel` entrypoint assumes a host-owned provider. ## \[0.1.0] - 2026-07-23 ### Added * Initial release: `@ratel-ai/vercel-ai-sdk`, the Vercel AI SDK adapter for Ratel. `ratel(config).adaptTo(aiSdk())` speaks the AI SDK's native `Tool` / `ModelMessage` shapes through the framework-adapter SPI (ADR-0013): the `ingest` / `expose` / `recallMessages` codecs plus two per-turn recall idioms — `appendRecall` (mutate-and-suffix-append, cache-preserving) and `prepareStep` (step-0 fresh-array override for `generateText` / `streamText` / `ToolLoopAgent`). Peers `@ratel-ai/sdk` with zero runtime dependencies; passes the `@ratel-ai/sdk/testkit` conformance battery (21 cases). Extracted from the live-verified `bratislava` prototype. (Supersedes the pre-release `@ratel-ai/ai-sdk-adapter@0.1.0-rc.1`, published under the old name before the rename.) * AI SDK v5, v6, and v7 support: the `ai` peer range is `^5.0.0 || ^6.0.0 || ^7.0.0` (`ai@4` predates the v5 tool/message reshape). One shared code path absorbs the per-major differences: provider-defined tools pass through under both discriminators (`provider-defined` in `ai@5`, `provider` in `ai@6`/`ai@7`), catalog executors receive both context spellings (`experimental_context` and `context`), and a Promise-like JSON Schema is rejected synchronously before the registration batch commits. CI pins an exact-version compatibility matrix (`ai@5.0.0`, `5.0.217`, `6.0.0`, `6.0.232`, `7.0.0`, `7.0.33`), each of which builds, typechecks, tests, packs, and typechecks a packed-tarball consumer. Narrowing the supported-majors peer range is a breaking change of the adapter (see the README's Compatibility section). * Live execution-context forwarding: when the model runs one of your tools through `invoke_tool`, the adapter forwards the AI SDK's complete live execution options (`toolCallId`, `messages`, `abortSignal`, and the version's `experimental_context` / `context`) unchanged to the tool, instead of a fabricated options object. The whole context rides through the catalog as an opaque value under a private, package-stable symbol tag (ADR-0013), so a sibling framework view over the same catalog can never have its context mistaken for AI SDK options. The driver-level `r.tools.catalog.invoke(id, args)` escape hatch — which has no AI SDK invocation to thread — keeps the fabricated fallback. Requires `@ratel-ai/sdk@^0.5.1`. ### Fixed * `prepareStep` preserves the injected recall pair across the steps of one `generateText` / `streamText` / `ToolLoopAgent` run on `ai@5`/`ai@6`, which rebuild the prompt per step (the pair is reinserted at its original boundary from per-run state); on `ai@7`, which carries the step-0 override forward itself, the duplicate check makes reinsertion a no-op. * Preserve AI SDK tool semantics through the capability funnel: nested input schemas now validate and apply defaults/transforms, streamed executors retain preliminary/final outputs, and target exceptions surface as native `tool-error` results. Tools with AI SDK-only lifecycle or model metadata stay eagerly exposed in their original shape, preserving approval, per-tool context routing, input hooks, and `toModelOutput`. --- # @ratel-ai/mastra Source: https://www.ratel.sh/docs/packages/typescript/mastra > v0.2.0 · TypeScript · [npm](https://www.npmjs.com/package/@ratel-ai/mastra) · [source](https://github.com/ratel-ai/ratel/tree/main/src/adapters/ts-mastra) > > Generated from the doc comments at `ratel-ai/ratel@d8635d5` — do not edit by hand. Regenerate with `pnpm sync:api` in `apps/docs`. `@ratel-ai/mastra` — the Mastra adapter for Ratel. Implements the `@ratel-ai/sdk` framework-adapter SPI (ADR-0013) for `@mastra/core`, so `ratel(config).adaptTo(mastra())` speaks Mastra's native `Tool` (from `createTool`) and `MastraDBMessage` shapes and adds a per-turn recall input processor for an Agent's `inputProcessors`. ## Interfaces ### MastraExt The Mastra-idiomatic per-turn recall helper [mastra](#api-mastra) merges onto the adapted view via the SPI's `extend` hook. #### Methods ##### recallProcessor() > **recallProcessor**(): `InputProcessor` A fresh Mastra InputProcessor for an Agent's `inputProcessors`. Its `processInput` runs once at the start of each generation (= once per user turn): it ranks the catalog against the last user message and injects the synthetic `search_capabilities` call+result before the model runs. A no-op that spends no call id unless the last message is a user turn with text and there are hits. A factory (not a shared instance) so several agents each get their own processor. ###### Returns `InputProcessor` ## Type Aliases ### MastraTool > **MastraTool** = `ToolsInput`\[`string`] A Mastra tool — the per-entry type of Mastra's `ToolsInput` (a `createTool` result, or an AI SDK / provider tool a Mastra Agent accepts). Deliberately broad so an app can register any tool its Agent could hold; `ingest` duck-types it. ## Functions ### mastra() > **mastra**(): `RatelAdapter`\<`ToolAction`\<`any`, `any`, `any`, `any`, `any`, `string`, `unknown`> | `VercelTool` | `VercelToolV5` | `ProviderDefinedTool`, `MastraDBMessage`, [`MastraExt`](#api-mastraext)> The Mastra adapter: `ratel(config).adaptTo(mastra())` gives the framework-neutral core `@mastra/core`'s native [MastraTool](#api-mastratool) (from `createTool`) and MastraDBMessage shapes. The core owns every guard (reserved ids, top-K clamp, first-registration-wins, recall-id counter), so the adapter is just the three codecs — `ingest` / `expose` / `recallMessages` — plus the [MastraExt](#api-mastraext) recall processor. #### Returns `RatelAdapter`\<`ToolAction`\<`any`, `any`, `any`, `any`, `any`, `string`, `unknown`> | `VercelTool` | `VercelToolV5` | `ProviderDefinedTool`, `MastraDBMessage`, [`MastraExt`](#api-mastraext)> A RatelAdapter over Mastra's tool and message types. --- # Changelog Source: https://www.ratel.sh/docs/packages/typescript/mastra/changelog > @ratel-ai/mastra release history · [npm](https://www.npmjs.com/package/@ratel-ai/mastra) · [source](https://github.com/ratel-ai/ratel/tree/main/src/adapters/ts-mastra) > > Synced verbatim from the package's `CHANGELOG.md` at `ratel-ai/ratel@d8635d5` — do not edit by hand. Regenerate with `pnpm sync:api` in `apps/docs`. All notable changes to `@ratel-ai/mastra` are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this package adheres to [Semantic Versioning](https://semver.org/). ## \[0.2.0] - 2026-07-28 ### Changed * The `@ratel-ai/sdk` peer range moves to `^0.6.0`, tracking the SDK release that adds experimental adaptive usage ranking. The adapter's own API is unchanged, and 0.6.0 is additive over 0.5.2 — the range moves because at 0.x a caret does not span minors, so `^0.5.2` excludes `0.6.x`. npm resolves peers automatically, which makes that mismatch an install-time `ERESOLVE` rather than a warning: `@ratel-ai/mastra@0.1.0` cannot be installed alongside `@ratel-ai/sdk@0.6.x`, and this release cannot be installed alongside `@ratel-ai/sdk@0.5.x`. Staying on the 0.5 SDK means staying on adapter 0.1.0. ## \[0.1.0] - 2026-07-24 ### Added * Initial release: `@ratel-ai/mastra`, the [Mastra](https://mastra.ai) (`@mastra/core`) adapter for Ratel. `ratel(config).adaptTo(mastra())` speaks Mastra's native `Tool` (from `createTool`) and `MastraDBMessage` shapes through the framework-adapter SPI (ADR-0013): the `ingest` / `expose` / `recallMessages` codecs plus a per-turn recall idiom — `recallProcessor()`, a Mastra `Processor` you drop into an Agent's `inputProcessors`. `ingest` reads Mastra's normalized input schema (so zod 3, zod 4, and raw JSON Schema tools all work); `expose` wraps the three capability tools as genuine `createTool` results; `recallMessages` encodes the synthetic `search_capabilities` call+result as one assistant message (`content.format: 2`, a single resolved `tool-invocation` part). Tool execution preserves Mastra's complete live `ToolExecutionContext` through `invoke_tool` — `requestContext`, workspace, agent thread/resource metadata, `mastra`, and `abortSignal` — so `requestContextSchema` values validate against the caller's real context and concurrent invocations stay isolated (direct `ToolCatalog.invoke` calls use the documented context-free fallback). Peers `@mastra/core@>=1.11.0 <2` (the adapter owns its no-op observer and validation-error guard locally, and is CI-tested against 1.11.0, 1.31.0, and 1.51.0), `zod@^3.25.0 || ^4.0.0`, and `@ratel-ai/sdk` (Node.js ≥ 22.13.0) with zero runtime dependencies; passes the `@ratel-ai/sdk/testkit` conformance battery. --- # @ratel-ai/telemetry Source: https://www.ratel.sh/docs/packages/typescript/telemetry > v0.2.0 · TypeScript · [npm](https://www.npmjs.com/package/@ratel-ai/telemetry) · [source](https://github.com/ratel-ai/ratel/tree/main/src/telemetry/ts) > > Generated from the doc comments at `ratel-ai/ratel@d8635d5` — do not edit by hand. Regenerate with `pnpm sync:api` in `apps/docs`. `@ratel-ai/telemetry` — the `ratel.*` telemetry vocabulary. See the wire contract in [CONVENTIONS.md](https://github.com/ratel-ai/ratel/blob/main/src/telemetry/CONVENTIONS.md). Emitting the vocabulary is done through the standard OpenTelemetry JS SDK; this package adds no transport and no schema (ADR-0007). These constants are the `ratel.*` overlay Ratel owns, plus the small subset of `gen_ai.*` keys the overlay emits directly on the `execute_tool` span (borrowed verbatim from OpenTelemetry, never renamed). This package is OTel-free (ADR-0007): the only behavior it carries beyond the constants is the content-capture gate, re-exported from `./content-capture`. Wiring a provider — endpoint, auth, exporters, processors, flush — is the host's job. ## Type Aliases ### AuthOutcome > **AuthOutcome** = *typeof* [`AuthOutcome`](#api-authoutcome-1)\[keyof *typeof* [`AuthOutcome`](#api-authoutcome-1)] Outcome of an MCP auth flow. Carried by `ratel.auth.outcome`; `NeedsAuth` is the 401-driven `AuthNeeds` case (ADR-0007 `auth_needs`). *** ### ContentCapture > **ContentCapture** = *typeof* [`ContentCapture`](#api-contentcapture-1)\[keyof *typeof* [`ContentCapture`](#api-contentcapture-1)] Message/tool content capture modes for `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` (CONVENTIONS.md § Capture gating). Default off. *** ### Origin > **Origin** = *typeof* [`Origin`](#api-origin-1)\[keyof *typeof* [`Origin`](#api-origin-1)] Whether a `ratel.*` span was a direct library call or synthesized by the agent inside its loop. Carried by `ratel.origin`; mirrors the local trace `Origin` (ADR-0007). *** ### SearchTarget > **SearchTarget** = *typeof* [`SearchTarget`](#api-searchtarget-1)\[keyof *typeof* [`SearchTarget`](#api-searchtarget-1)] What a `ratel.search` span was searching. Carried by `ratel.search.target`; folds capability-tool search and skill search into one span shape. ## Variables ### AuthOutcome > `const` **AuthOutcome**: `object` Outcome of an MCP auth flow. Carried by `ratel.auth.outcome`; `NeedsAuth` is the 401-driven `AuthNeeds` case (ADR-0007 `auth_needs`). #### Type Declaration | Name | Type | Default value | | ------------ | -------------- | -------------- | | `Failed` | `"failed"` | `"failed"` | | `NeedsAuth` | `"needs_auth"` | `"needs_auth"` | | `Ok` | `"ok"` | `"ok"` | | `Refreshed` | `"refreshed"` | `"refreshed"` | *** ### CAPTURE\_CONTENT\_ENV > `const` **CAPTURE\_CONTENT\_ENV**: `"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"` = `"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"` The ecosystem instrumentation env var gating message/tool content capture. Default off; the shared ecosystem name rather than a Ratel-invented flag (CONVENTIONS.md § Capture gating). Values: legacy boolean, or the enum `NO_CONTENT` (default) / `SPAN_ONLY` / `EVENT_ONLY` / `SPAN_AND_EVENT`. *** ### ContentCapture > `const` **ContentCapture**: `object` Message/tool content capture modes for `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` (CONVENTIONS.md § Capture gating). Default off. #### Type Declaration | Name | Type | Default value | | --------------- | ------------------ | ------------------ | | `EventOnly` | `"EVENT_ONLY"` | `"EVENT_ONLY"` | | `NoContent` | `"NO_CONTENT"` | `"NO_CONTENT"` | | `SpanAndEvent` | `"SPAN_AND_EVENT"` | `"SPAN_AND_EVENT"` | | `SpanOnly` | `"SPAN_ONLY"` | `"SPAN_ONLY"` | *** ### EXECUTE\_TOOL > `const` **EXECUTE\_TOOL**: `"execute_tool"` = `"execute_tool"` `execute_tool` — the `gen_ai.operation.name` value for a tool invocation. Deliberately the standard OTel `gen_ai` operation, not a bespoke `ratel.invoke` span, so a generic OTel backend already understands it (locked 2026-07-05). The invoke is enriched with `ratel.*` attributes. *** ### GEN\_AI\_INFERENCE\_DETAILS > `const` **GEN\_AI\_INFERENCE\_DETAILS**: `"gen_ai.client.inference.operation.details"` = `"gen_ai.client.inference.operation.details"` `gen_ai.client.inference.operation.details` — the event that carries inference request and response content. Borrowed from gen\_ai (Tier 1). *** ### GEN\_AI\_INPUT\_MESSAGES > `const` **GEN\_AI\_INPUT\_MESSAGES**: `"gen_ai.input.messages"` = `"gen_ai.input.messages"` `gen_ai.input.messages` — the input message list (Opt-In content). *** ### GEN\_AI\_OPERATION\_NAME > `const` **GEN\_AI\_OPERATION\_NAME**: `"gen_ai.operation.name"` = `"gen_ai.operation.name"` `gen_ai.operation.name` — set to [EXECUTE\_TOOL](#api-execute_tool) for a tool invocation. *** ### GEN\_AI\_OUTPUT\_MESSAGES > `const` **GEN\_AI\_OUTPUT\_MESSAGES**: `"gen_ai.output.messages"` = `"gen_ai.output.messages"` `gen_ai.output.messages` — generated outputs; every message includes `finish_reason`. *** ### GEN\_AI\_SYSTEM\_INSTRUCTIONS > `const` **GEN\_AI\_SYSTEM\_INSTRUCTIONS**: `"gen_ai.system_instructions"` = `"gen_ai.system_instructions"` `gen_ai.system_instructions` — the system prompt as a bare `parts[]` (Opt-In content). *** ### GEN\_AI\_TOOL\_CALL\_ARGUMENTS > `const` **GEN\_AI\_TOOL\_CALL\_ARGUMENTS**: `"gen_ai.tool.call.arguments"` = `"gen_ai.tool.call.arguments"` `gen_ai.tool.call.arguments` — tool arguments (Opt-In content, gated). *** ### GEN\_AI\_TOOL\_CALL\_ID > `const` **GEN\_AI\_TOOL\_CALL\_ID**: `"gen_ai.tool.call.id"` = `"gen_ai.tool.call.id"` `gen_ai.tool.call.id` — tool call id, when available. *** ### GEN\_AI\_TOOL\_CALL\_RESULT > `const` **GEN\_AI\_TOOL\_CALL\_RESULT**: `"gen_ai.tool.call.result"` = `"gen_ai.tool.call.result"` `gen_ai.tool.call.result` — tool result (Opt-In content, gated). *** ### GEN\_AI\_TOOL\_NAME > `const` **GEN\_AI\_TOOL\_NAME**: `"gen_ai.tool.name"` = `"gen_ai.tool.name"` `gen_ai.tool.name` — the capability tool id. *** ### Origin > `const` **Origin**: `object` Whether a `ratel.*` span was a direct library call or synthesized by the agent inside its loop. Carried by `ratel.origin`; mirrors the local trace `Origin` (ADR-0007). #### Type Declaration | Name | Type | Default value | | --------- | ---------- | ------------- | | `Agent` | `"agent"` | `"agent"` | | `Direct` | `"direct"` | `"direct"` | *** ### RATEL\_AUTH\_FLOW > `const` **RATEL\_AUTH\_FLOW**: `"ratel.auth.flow"` = `"ratel.auth.flow"` `ratel.auth.flow` — MCP auth flow. *** ### RATEL\_AUTH\_OUTCOME > `const` **RATEL\_AUTH\_OUTCOME**: `"ratel.auth.outcome"` = `"ratel.auth.outcome"` `ratel.auth.outcome` — `ok` / `refreshed` / `needs_auth` / `failed` (see [AuthOutcome](#api-authoutcome-1)). *** ### RATEL\_ORIGIN > `const` **RATEL\_ORIGIN**: `"ratel.origin"` = `"ratel.origin"` `ratel.origin` — direct library call vs agent-synthesized (shared attribute). *** ### RATEL\_SEARCH > `const` **RATEL\_SEARCH**: `"ratel.search"` = `"ratel.search"` `ratel.search` — capability search (unifies tool-search and skill-search). *** ### RATEL\_SEARCH\_HIT\_COUNT > `const` **RATEL\_SEARCH\_HIT\_COUNT**: `"ratel.search.hit_count"` = `"ratel.search.hit_count"` `ratel.search.hit_count` — results returned. *** ### RATEL\_SEARCH\_QUERY > `const` **RATEL\_SEARCH\_QUERY**: `"ratel.search.query"` = `"ratel.search.query"` `ratel.search.query` — the search text (content, gated like message content). *** ### RATEL\_SEARCH\_RESULTS > `const` **RATEL\_SEARCH\_RESULTS**: `"ratel.search.results"` = `"ratel.search.results"` `ratel.search.results` — Opt-In search-content event; gated like content. *** ### RATEL\_SEARCH\_TARGET > `const` **RATEL\_SEARCH\_TARGET**: `"ratel.search.target"` = `"ratel.search.target"` `ratel.search.target` — `tool` or `skill` (see [SearchTarget](#api-searchtarget-1)). *** ### RATEL\_SEARCH\_TOP\_K > `const` **RATEL\_SEARCH\_TOP\_K**: `"ratel.search.top_k"` = `"ratel.search.top_k"` `ratel.search.top_k` — requested result count. *** ### RATEL\_SKILL\_ID > `const` **RATEL\_SKILL\_ID**: `"ratel.skill.id"` = `"ratel.skill.id"` `ratel.skill.id` — skill loaded on the `ratel.skill.load` span. *** ### RATEL\_SKILL\_LOAD > `const` **RATEL\_SKILL\_LOAD**: `"ratel.skill.load"` = `"ratel.skill.load"` `ratel.skill.load` — skill content load (`get_skill_content`). *** ### RATEL\_TOOL\_ARGS\_SIZE\_BYTES > `const` **RATEL\_TOOL\_ARGS\_SIZE\_BYTES**: `"ratel.tool.args_size_bytes"` = `"ratel.tool.args_size_bytes"` `ratel.tool.args_size_bytes` — argument payload size on the `execute_tool` span. *** ### RATEL\_TOOL\_EXECUTION\_DETAILS > `const` **RATEL\_TOOL\_EXECUTION\_DETAILS**: `"ratel.tool.execution.details"` = `"ratel.tool.execution.details"` `ratel.tool.execution.details` — Opt-In structured tool arguments/result event. *** ### RATEL\_UPSTREAM\_REGISTER > `const` **RATEL\_UPSTREAM\_REGISTER**: `"ratel.upstream.register"` = `"ratel.upstream.register"` `ratel.upstream.register` — upstream-MCP ingest. *** ### RATEL\_UPSTREAM\_SERVER > `const` **RATEL\_UPSTREAM\_SERVER**: `"ratel.upstream.server"` = `"ratel.upstream.server"` `ratel.upstream.server` — upstream MCP server backing a tool / auth flow. *** ### RATEL\_UPSTREAM\_TOOL\_COUNT > `const` **RATEL\_UPSTREAM\_TOOL\_COUNT**: `"ratel.upstream.tool_count"` = `"ratel.upstream.tool_count"` `ratel.upstream.tool_count` — tools ingested on register. *** ### RATEL\_UPSTREAM\_TRANSPORT > `const` **RATEL\_UPSTREAM\_TRANSPORT**: `"ratel.upstream.transport"` = `"ratel.upstream.transport"` `ratel.upstream.transport` — `stdio` / `http` / `sse` / ... *** ### SearchTarget > `const` **SearchTarget**: `object` What a `ratel.search` span was searching. Carried by `ratel.search.target`; folds capability-tool search and skill search into one span shape. #### Type Declaration | Name | Type | Default value | | -------- | --------- | ------------- | | `Skill` | `"skill"` | `"skill"` | | `Tool` | `"tool"` | `"tool"` | *** ### SEMCONV\_VERSION > `const` **SEMCONV\_VERSION**: `"1.42.0"` = `"1.42.0"` The pinned OpenTelemetry semantic-conventions version this vocabulary tracks (the `gen_ai` group). The pin is the contract; consumers read against this exact version, never "latest" (CONVENTIONS.md § The pin). ## Functions ### clearContentCapture() > **clearContentCapture**(`generation`): `void` Clear the programmatic content-capture override, but only when `generation` — the token returned by [setContentCapture](#api-setcontentcapture) — still identifies the most recent set. A stale token no-ops, so an old handle shutting down late cannot clobber an override a newer caller installed (and silently re-enable, or disable, capture via the env fallback). For an unconditional clear, use `setContentCapture(null)`. #### Parameters | Parameter | Type | | ------------ | -------- | | `generation` | `number` | #### Returns `void` *** ### contentCaptureMode() > **contentCaptureMode**(`env?`): [`ContentCapture`](#api-contentcapture) Parse the ecosystem content-capture gate. A mode set via [setContentCapture](#api-setcontentcapture) wins outright (env is the fallback, as in OTel); otherwise default [ContentCapture.NoContent](#api-property-nocontent) when unset/empty/unrecognized. The legacy boolean form maps `true` to full capture ([ContentCapture.SpanAndEvent](#api-property-spanandevent)) and `false` to none. #### Parameters | Parameter | Type | Default value | | --------- | -------------------------------------------- | ------------- | | `env` | `Record`\<`string`, `string` \| `undefined`> | `process.env` | #### Returns [`ContentCapture`](#api-contentcapture) *** ### setContentCapture() > **setContentCapture**(`mode`): `number` Programmatically set the content-capture mode. While set, [contentCaptureMode](#api-contentcapturemode) returns this mode regardless of `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` — programmatic config wins over the environment, matching how OpenTelemetry treats env vars as the fallback for code-level configuration. Pass `null`/`undefined` to clear the override unconditionally and return to env-driven parsing. The mode is validated like the env var (case-insensitive, legacy `true`/`false`/ `1`/`0` accepted) and **throws a `TypeError`** on anything unrecognized — failing loud at config time instead of storing a value that would both disable capture and mask the env var. Returns a generation token identifying this call as the current owner of the override; pass it to [clearContentCapture](#api-clearcontentcapture) to clear only if no newer set has happened since (the safe form for shutdown/teardown hooks). #### Parameters | Parameter | Type | | --------- | ---------------------------------------------------------------- | | `mode` | [`ContentCapture`](#api-contentcapture) \| `null` \| `undefined` | #### Returns `number` --- # Changelog Source: https://www.ratel.sh/docs/packages/typescript/telemetry/changelog > @ratel-ai/telemetry release history · [npm](https://www.npmjs.com/package/@ratel-ai/telemetry) · [source](https://github.com/ratel-ai/ratel/tree/main/src/telemetry/ts) > > Synced verbatim from the package's `CHANGELOG.md` at `ratel-ai/ratel@d8635d5` — do not edit by hand. Regenerate with `pnpm sync:api` in `apps/docs`. All notable changes to `@ratel-ai/telemetry` are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this package adheres to [Semantic Versioning](https://semver.org/). ## \[0.2.0] - 2026-07-26 ### Removed * **BREAKING:** the OTLP exporter configuration — `resolveOtlpConfig()` with its `InitOptions` / `ResolvedOtlpConfig` types, and the `OTLP_ENDPOINT_ENV`, `API_KEY_ENV`, and `DEFAULT_SERVICE_NAME` constants. This package is now the `ratel.*` vocabulary plus the content-capture gate, nothing else. A host that owns the OpenTelemetry provider already owns the endpoint and auth that feed its exporters, so resolving them here was a second, Ratel-specific config path beside the standard `OTEL_EXPORTER_OTLP_*` one. To migrate, read your own endpoint, derive the Logs URL by replacing the traces URL's terminal `/v1/traces` with `/v1/logs`, and set `Authorization: Bearer ` — see [`examples/telemetry-ts`](https://github.com/ratel-ai/ratel/blob/main/examples/telemetry-ts/README.md) for the whole of it. ### Changed * The companion `@ratel-ai/telemetry-otlp` package is gone: TypeScript hosts own the OpenTelemetry provider and build the OTLP exporters and span/log-record processors themselves. The `ratel.*` constants, the value enums, `SEMCONV_VERSION`, and the content-capture gate are unchanged. * `ratel.origin` is now specified for third-party `gen_ai.*` spans that a framework adapter overlays, not just Ratel's own search/invoke spans. The key, its `direct | agent` values, and `SEMCONV_VERSION` are unchanged; only the vocabulary spec widened (`CONVENTIONS.md`), so on the overlay path the value is host-selectable and defaults to `agent`. ## \[0.1.3] - 2026-07-24 ### Added * `GEN_AI_SYSTEM_INSTRUCTIONS`, `GEN_AI_INPUT_MESSAGES`, `GEN_AI_OUTPUT_MESSAGES`, `RATEL_TOOL_EXECUTION_DETAILS`, and a distinct Logs endpoint in resolved OTLP configuration. ### Changed * Define content events as structured OpenTelemetry Logs `EventRecord`s and reserve inference output messages for model outputs with `finish_reason`. ## \[0.1.2] - 2026-07-11 ### Added * `API_KEY_ENV` (`RATEL_API_KEY`) and API-key environment fallback in `resolveOtlpConfig`. An explicit `apiKey` remains authoritative; the env fallback applies only when neither `apiKey` nor an explicit `Authorization` header is given, so ambient `RATEL_API_KEY` never clobbers a caller-supplied auth header. ## \[0.1.1] - 2026-07-10 ### Added * `setContentCapture(mode)`: programmatic override of the content-capture gate. While set, `contentCaptureMode()` returns the given mode regardless of `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` — code-level config wins over the environment, matching how OpenTelemetry treats env vars as the fallback for programmatic configuration. The mode is validated exactly like the env var (case-insensitive, trimmed, legacy `true`/`false`/`1`/`0` forms accepted) and throws a `TypeError` naming the valid values on anything unrecognized — failing loud at config time instead of storing a value that would both disable capture and mask the env var. Pass `null`/`undefined` to clear unconditionally. Returns a generation token identifying the call as the current owner of the override. * `clearContentCapture(generation)`: clears the override only when `generation` (the token returned by `setContentCapture`) still identifies the most recent set. A stale token no-ops, so an old telemetry handle shutting down late cannot clobber an override a newer caller installed and silently flip capture back to the env value. ## \[0.1.0] - 2026-07-06 ### Added * The telemetry helper (ADR-0015): the full `ratel.*` vocabulary as constants (attribute keys, span/event names, `gen_ai.*` interop keys, and the `Origin`/`SearchTarget`/`AuthOutcome` value enums) pinned to OpenTelemetry semconv `gen_ai` v1.42.0. * Shared contract-against-the-pin conformance suite (`../conformance/fixtures.json`): spans built from the constants through the real SDK must emit the exact pinned keys. * Usage example in the README (runnable end-to-end in `examples/telemetry-ts`). * A regression guard that no `@opentelemetry/*` runtime dependency or shipped-source import can creep back into the vocabulary package. ### Changed * `init()` lives in `@ratel-ai/telemetry-otlp`, not this package: importing `@ratel-ai/telemetry` pulls no OpenTelemetry SDK (ADR-0015), so the SDK (emit), the server (read), and edge/serverless emitters take the `ratel.*` vocabulary weight-free. This package keeps the constants plus the pure `resolveOtlpConfig` / `contentCaptureMode`; callers of `init()` install `@ratel-ai/telemetry-otlp` and import it from there. * Released as an independent npm unit under the `telemetry-ts-v*` tag prefix. --- # @ratel-ai/ratel-local Source: https://www.ratel.sh/docs/packages/typescript/mcp-server `@ratel-ai/ratel-local` is the TypeScript package behind [Ratel Local](https://www.ratel.sh/docs/local). It connects upstream MCP servers to one searchable catalog and exposes Ratel's capability tools to Claude Code, Codex, Cursor, and other MCP clients. > **Start with Ratel Local** > > Install the latest stable release and run the setup wizard for Claude Code or Codex. Follow the > [Ratel Local quickstart](https://www.ratel.sh/docs/local/quickstart) to prepare the daemon, connect agents, > review an existing MCP setup when needed, and verify capability search. Cursor uses > [manual MCP configuration](https://www.ratel.sh/docs/local/cursor). ## Entry points | Entry point | Purpose | | ------------------------ | ------------------------------------------------------------------------ | | `ratel-local` | Run setup; manage the daemon, upstreams, retrieval, skills, and local UI | | `createMcpServer` | Expose a `ToolCatalog` as an MCP server from your own process | | `buildGatewayFromConfig` | Connect configured upstreams and build the catalog used by the server | Install with `npm install @ratel-ai/ratel-local`, or run its CLI with `npx -y @ratel-ai/ratel-local`. ## Operational guides | Task | Guide | | -------------------------------------------------- | ------------------------------------------------------------------------------------ | | install the daemon and connect supported agents | [Quickstart](https://www.ratel.sh/docs/local/quickstart) | | manage the persistent service and project gateways | [Daemon](https://www.ratel.sh/docs/local/daemon) | | add, inspect, edit, or remove upstreams | [MCP servers](https://www.ratel.sh/docs/local/mcp-servers) | | configure BM25, semantic, or hybrid search | [Retrieval](https://www.ratel.sh/docs/local/retrieval) | | migrate native Claude Code or Codex entries | [Import or link](https://www.ratel.sh/docs/local/import-or-link) | | authorize HTTP or SSE upstreams | [OAuth upstreams](https://www.ratel.sh/docs/local/oauth) | | manage skills and the Claude Code preload hook | [Managing skills](https://www.ratel.sh/docs/local/managing-skills) | | manage configuration from the browser | [Browser UI](https://www.ratel.sh/docs/local/browser-ui) | | inspect local events and estimated savings | [Statusline & local telemetry](https://www.ratel.sh/docs/local/statusline-telemetry) | | opt into the experimental Cloud relay | [Experimental Cloud telemetry](https://www.ratel.sh/docs/local/cloud-telemetry) | | create the gateway from Node code | [Embed as a library](https://www.ratel.sh/docs/local/library) | | diagnose setup and recover config files | [Troubleshooting](https://www.ratel.sh/docs/local/troubleshooting) | The package is published on [npm](https://www.npmjs.com/package/@ratel-ai/ratel-local), with source in [ratel-ai/ratel-local](https://github.com/ratel-ai/ratel-local). --- # ratel-ai Source: https://www.ratel.sh/docs/packages/python/ratel-ai > v0.7.0 · Python · [PyPI](https://pypi.org/project/ratel-ai/) · [source](https://github.com/ratel-ai/ratel/tree/main/src/sdk/python) > > Generated from the doc comments at `ratel-ai/ratel@d8635d5` — do not edit by hand. Regenerate with `pnpm sync:api` in `apps/docs`. ## Classes ### AdaptiveRankingStatus The adaptive-ranking status, enriched with the models a pause involves. A plain `str` — `== "active"` and `.startswith("paused")` work as before — that also exposes, when the arm is paused by a model change, the fingerprint the graph was `built` with, the currently `active` model, and whether it is a `dim_mismatch` (different width) vs a same-width different model. All three are `None` unless the status is a `paused: ...`. Mirrors the object the TS SDK returns, so the detail is reachable without parsing stderr. **Attributes:** * `active: str | None` * `built: str | None` * `dim_mismatch: bool | None` ### IntentGraph ```python IntentGraph() ``` A shared usage-ranking intent graph (ADR-0014). Clusters of past queries, each remembering the capabilities invoked after them. Hand the *same* instance to a tool catalog and a skill catalog: one cluster carries both a tool and a skill edge map, so sharing gives one set of clusters with all the evidence behind it, while separate graphs duplicate every cluster and split the evidence. An empty graph — knows nothing until a search is followed by an invoke. **Attributes:** * `cluster_count: int` — How many clusters the graph holds. `0` is the cold-start state, in which it contributes nothing to ranking. * `rev: int` — Monotonic write counter, bumped once per mutation. Never affects ranking — a primitive for your storage layer. Snapshot it after each save: a later value means unsaved learning (save-when-changed), and a stored graph whose `rev` is higher than the one you loaded was written by another process (stale-base detection). #### from\_json ```python from_json(json: str) -> IntentGraph ``` Adopt a graph in the `protocol/v1` wire form. Accepts output from `ratel-graph build`, a previous `to_json()`, or Ratel Cloud. Raises `ValueError` if it is malformed or declares a schema version this build does not read. #### to\_json ```python to_json() -> str ``` Serialize to the `protocol/v1` wire form. For inspection, or to carry what was learned across processes. The graph is in-process only; persistence is yours. It mutates on every confirmed invoke, so unsaved observations are lost on a crash — persist on a cadence or at shutdown. Use `rev` to save only when it changed and to detect a concurrent writer; single-writer is the supported model. SENSITIVE: the output contains the raw text of past user queries (the cluster `members`). Treat a persisted graph like your query/telemetry log — restrict permissions (`0600`), keep it out of version control and images, and do not ship it to a less-trusted store. ### DimensionMismatchError A query/corpus embedding dimension mismatch. ### EmbedderError Embedding model load / inference failure (subclass of RuntimeError). ### EndpointEmbeddingConfig OpenAI-compatible embedding endpoint configuration. **Attributes:** * `model: str` * `url: str` ### ExecutableTool ```python ExecutableTool(id: str, name: str, description: str, input_schema: dict[str, Any] = dict(), output_schema: dict[str, Any] = dict(), execute: Executor = None) ``` A `Tool` plus the handler that runs it. Registered into a `ToolCatalog`. **Attributes:** * `execute: Executor = field(default=None)` ### HuggingFaceEmbeddingConfig In-process HuggingFace embedding model configuration. **Attributes:** * `huggingface: str` ### LocalEmbeddingConfig In-process local-directory embedding model configuration. **Attributes:** * `local: str` ### McpServerHandle ```python McpServerHandle(tool_ids: list[str], server_instructions: str | None, close: Callable[[], Awaitable[None]]) ``` What `register_mcp_server` returns: the registration's outcome. **Attributes:** * `tool_ids` (`list[str]`) — namespaced `__` ids in upstream list order (all pages). Duplicate tool names may appear more than once; `ToolCatalog.register` keeps the last row per id. * `server_instructions` (`str | None`) — the `instructions` value passed to `register_mcp_server` (the caller reads it from its own `initialize` result), or `None`. * `close` (`Callable[[], Awaitable[None]]`) — async teardown — the `on_close` passed to `register_mcp_server`, or a no-op. The session itself stays caller-owned either way. ### McpToolsListError ```python McpToolsListError(message: str, code: McpToolsListErrorCode) ``` Raised when MCP `tools/list` pagination is invalid or exceeds the page cap. Set the error message and pagination failure code. **Attributes:** * `code = code` * `name = 'McpToolsListError'` ### OllamaEmbeddingConfig Local Ollama embedding endpoint configuration. **Attributes:** * `ollama: str` ### PendingReplace ```python PendingReplace(added: int = 0, removed: int = 0, updated: int = 0, unchanged: int = 0, _driver: Awaitable[None] | None = None) ``` What `replace_all` returns: final counts over a pending embedding pass. The corpus swap commits synchronously, so the counts are already final when `replace_all` returns and can be read before the embedding pass settles — a source can report what a reload changed even when that pass fails. **Always** `await` **the result** regardless, so the embedding pass runs and its failure is raised rather than swallowed. Note that this compares unequal to a bare `ReplaceOutcome` with the same counts: a dataclass `__eq__` requires both sides to be the same class, so no field configuration changes that. Await first — the awaited value *is* a plain `ReplaceOutcome` — and compare that. ### ReplaceOutcome ```python ReplaceOutcome(added: int = 0, removed: int = 0, updated: int = 0, unchanged: int = 0) ``` What a `replace_all` changed, counted by id. `updated` covers any field edit (including a body-only rewrite); `unchanged` ids are identical to what was registered and keep their cached embedding, so a reload that changed nothing costs no embedding calls. **Attributes:** * `added: int = 0` * `removed: int = 0` * `unchanged: int = 0` * `updated: int = 0` ### SearchHit A single search result: the matched tool id and its relevance score. **Attributes:** * `fused: bool` — Whether `score` is a Reciprocal Rank Fusion score (ordering-only). `True` when the usage arm fused into this search or the method is hybrid; `False` for a plain BM25/semantic result. Uniform across one result list; lets you detect which scale `score` is on. * `rank: int` — 0-based position in this result list (best is 0). Stable across methods and across the `fused` switch — the field to order or threshold on, in place of the scale-shifting `score`. * `score: float` — Relevance score; higher ranks first. The scale depends on the search method (raw BM25 / cosine / RRF) AND on `fused` — with adaptive ranking a matched query returns small RRF scores while an unmatched one on the same catalog returns the raw score. Order by `rank`, branch on `fused`; treat `score` as a within-list hint only. * `tool_id: str` — Id of the matched tool, as passed to `register`. ### Skill ```python Skill(id: str, name: str, description: str, tags: list[str] = list(), tools: list[str] = list(), metadata: dict[str, list[str]] = dict(), body: str = '') ``` Skill metadata: what the index ranks and the capability tools surface. **Attributes:** * `body: str = ''` * `description: str` * `id: str` * `metadata: dict[str, list[str]] = field(default_factory=dict)` * `name: str` * `tags: list[str] = field(default_factory=list)` * `tools: list[str] = field(default_factory=list)` ### SkillCatalog ```python SkillCatalog(trace: TraceSinkConfig | None = None, method: SearchMethod = 'bm25', embedding: EmbeddingSpec | None = None) ``` Registry of skills. Register once, then search and load bodies by id. Create an empty skill catalog. **Parameters:** * `trace` (`TraceSinkConfig | None`) — where trace events go; `None` keeps the default no-op sink. * `method` (`SearchMethod`) — default retrieval method for `search` — see `ToolCatalog.__init__`; a "semantic"/"hybrid" catalog embeds inside `register`, and dense results come from `search_async`. * `embedding` (`EmbeddingSpec | None`) — model for semantic/hybrid retrieval — see `ToolCatalog.__init__`; retained and validated under "bm25" too. **Attributes:** * `experimental_adaptive_ranking_status: AdaptiveRankingStatus` — Adaptive-ranking status: active, inactive, unknown, or paused. #### drain\_trace\_events ```python drain_trace_events() -> list[dict[str, Any]] ``` Drain captured trace envelopes; `[]` unless the sink is "memory". #### experimental\_disable\_adaptive\_ranking ```python experimental_disable_adaptive_ranking() -> None ``` Turn adaptive usage ranking off; the graph keeps what it learned. #### experimental\_enable\_adaptive\_ranking ```python experimental_enable_adaptive_ranking(graph: IntentGraph, *, warn_on_model_mismatch: bool = True, rebuild_on_model_change: bool = False) -> None ``` Turn on adaptive usage ranking against `graph` (ADR-0014). Wires both halves: this catalog ranks against what users have actually invoked after similar queries, and keeps learning as it is used. Pass the same :class:`IntentGraph` to the other catalog so both learn into one set of clusters. Only queries matching a cluster are affected. With a graph attached the hit `score` becomes a fusion score rather than a raw BM25 score, so use `rank` for ordering and `fused` to detect the scale, not the raw `score`. Set `rebuild_on_model_change` to auto-recover a model-mismatched graph on the next dense search rather than staying paused until you call :meth:`experimental_rebuild_intent_graph` yourself. Off by default — the rebuild is an embedding pass (cost, possible :class:`EmbedderError`, mutates the graph). #### experimental\_rebuild\_intent\_graph ```python async experimental_rebuild_intent_graph() -> None ``` Re-embed the graph's members under the current model; preserves learning. #### get ```python get(skill_id: str) -> Skill | None ``` Return the registered `Skill` for an id, or `None` if unknown. #### has ```python has(skill_id: str) -> bool ``` Return whether a skill with this id is registered. #### invoke ```python invoke(skill_id: str) -> str ``` Return a skill's body for dispatch, recording a `skill_invoke` event. Synchronous, unlike `ToolCatalog.invoke` — the body is already in memory, there is no handler to run. **Parameters:** * `skill_id` (`str`) — id of a registered skill. **Returns:** `str` — The skill's body (Markdown), verbatim as registered. **Raises:** * `ValueError` — on an unknown id — callers at the capability-tool boundary translate this into a structured error for the agent. #### record\_event ```python record_event(event: dict[str, Any]) -> None ``` Record a trace event into the catalog's sink. See `ToolCatalog.record_event` for the event contract. #### register ```python register(skills: Skill | Iterable[Skill]) -> Awaitable[None] ``` Register one skill or many — the single entry point for both. Metadata is stored **synchronously** when `register(...)` is called (name/description/tags are indexed; `tools`, `metadata`, `body` are stored but not indexed), so a forgotten `await` never drops the corpus. The returned awaitable drives only the embedding pass: on a "semantic"/"hybrid" catalog it embeds the batch off-thread and surfaces errors when awaited; a BM25 catalog never loads a model. **Always `await` the result.** Re-registering an id replaces it in place. A model or dimension change is not recovered in place — construct a new catalog and re-register. **Parameters:** * `skills` (`Skill | Iterable[Skill]`) — a single `Skill` or an iterable of them. Pass the whole batch at once for a single embedding request. **Raises:** * `EmbedderError` — on a semantic/hybrid catalog, if embedding fails (when awaited). * `RuntimeError` — if another operation already owns the registry — dense work, but also an in-flight BM25 `search_async` holding the read lock. `registry busy` is retryable, not fatal. #### replace\_all ```python replace_all(skills: Iterable[Skill]) -> PendingReplace ``` Make `skills` the entire content of the catalog. Ids absent from the batch are removed, the rest are added or updated — the whole-catalog counterpart to `register`, for a source that reloads a catalog rather than pushing individual changes. Mutates in place, so every holder of this catalog observes the reload without being rebuilt. **Removal is unconditional.** Skills registered in-process are dropped just like any others, since the batch *is* the catalog. A host that keeps local skills alongside a remote source composes the batch itself: `await catalog.replace_all([*local_skills, *remote_skills])`. Like `register`, the corpus swap lands synchronously and the returned awaitable drives only the embedding pass — which embeds just the new and re-worded skills, so reloading an unchanged catalog costs nothing. If it fails, the new corpus is already live and BM25 still ranks it; semantic search raises until a later pass succeeds. **Always `await` the result.** **Parameters:** * `skills` (`Iterable[Skill]`) — the complete catalog contents. A repeated id keeps its last entry; an empty iterable clears the catalog. **Returns:** `PendingReplace` — A `PendingReplace`: the counts, already final, over the pending embedding pass. Read them without awaiting; `await` the result to drive the embedding pass. **Raises:** * `TypeError` — if any item is not a `Skill`. * `EmbedderError` — on a semantic/hybrid catalog, if embedding fails (when awaited). * `RuntimeError` — if another operation already owns the registry — dense work, but also an in-flight BM25 `search_async` holding the read lock. `registry busy` is retryable, not fatal. #### search ```python search(query: str, top_k: int, origin: SearchOrigin = 'direct', method: SearchMethod | None = None) -> list[SkillHit] ``` Rank registered skills synchronously with BM25. The skill twin of `ToolCatalog.search`: a dense resolved method raises immediately with guidance to use `search_async`. **Returns:** `list[SkillHit]` — Up to `top_k` `SkillHit`s, best first. #### search\_async ```python async search_async(query: str, top_k: int, origin: SearchOrigin = 'direct', method: SearchMethod | None = None) -> list[SkillHit] ``` Rank skills asynchronously with BM25, semantic, or hybrid retrieval. Dense methods require a complete cache built explicitly beforehand. #### size ```python size() -> int ``` Return the number of registered skills. ### SkillHit A single skill search result: the matched skill id and its relevance score. The skill analogue of `SearchHit` (`tool_id` → `skill_id`). **Attributes:** * `fused: bool` — Whether `score` is an RRF score — as on `SearchHit.fused`. * `rank: int` — 0-based position — as on `SearchHit.rank`. * `score: float` — Relevance score; higher ranks first. Scale depends on the method and on `fused`, as on `SearchHit.score`. Order by `rank`, branch on `fused`. * `skill_id: str` — Id of the matched skill, as passed to `register`. ### SkillRegistry ```python SkillRegistry(embedding: EmbeddingSpec | None = None, *, method: SearchMethod = 'bm25', spec: str | None = None, huggingface: str | None = None, local: str | None = None, ollama: str | None = None, url: str | None = None, model: str | None = None, revision: str | None = None, api_key_env: str | None = None, query_prefix: str | None = None, doc_prefix: str | None = None, pooling: str | None = None, download: bool | None = None) ``` Typed Python facade over the private native skill registry. Create a metadata registry with an optional embedding model. A "semantic"/"hybrid" `method` makes `register` embed eagerly (inside the call, on a worker thread); "bm25" keeps registration model-free. **Attributes:** * `experimental_adaptive_ranking_status: AdaptiveRankingStatus` — Adaptive-ranking status; a str that also carries a pause's model detail. #### drain\_trace\_events ```python drain_trace_events() -> list[dict[str, Any]] ``` Drain captured native trace events. #### experimental\_disable\_adaptive\_ranking ```python experimental_disable_adaptive_ranking() -> None ``` Turn adaptive usage ranking off; the graph keeps what it learned. #### experimental\_enable\_adaptive\_ranking ```python experimental_enable_adaptive_ranking(graph: IntentGraph, *, warn_on_model_mismatch: bool = True, rebuild_on_model_change: bool = False) -> None ``` Turn on adaptive usage ranking against `graph` (ADR-0014). Wires both halves: this registry ranks against what users have actually invoked after similar queries, and keeps learning as it is used. Pass the same :class:`IntentGraph` to the other registry so both learn into one set of clusters. Only queries matching a cluster are affected. With a graph attached the hit `score` becomes a fusion score rather than a raw BM25 score, so use `rank` for ordering and `fused` to detect the scale, not the raw `score`. Set `rebuild_on_model_change` to recover automatically instead: the next dense (semantic/hybrid) search re-embeds the graph under the current model before searching, so a persisted graph self-heals after a model swap. It is off by default because the rebuild is an embedding pass — expensive, able to raise :class:`EmbedderError`, and it mutates the graph (new centroids, bumped `rev`). Recovery is lazy: status stays `paused` until that first dense search. #### experimental\_rebuild\_intent\_graph ```python async experimental_rebuild_intent_graph() -> None ``` Re-embed the graph's members under the current model; preserves learning. #### record\_event ```python record_event(event: dict[str, Any]) -> None ``` Record an SDK-layer trace event. #### register ```python register(item: Skill | Iterable[Skill] | str, name: str | None = None, description: str | None = None, tags: list[str] | None = None, tools: list[str] | None = None, metadata: dict[str, list[str]] | None = None, body: str | None = None) -> Awaitable[None] ``` Register one `Skill`, many `Skill`s, or a flat (id, name, …) tuple. Metadata is indexed **synchronously** when `register(...)` is called (a forgotten `await` never drops the corpus); the returned awaitable drives only the embedding pass. On a "semantic"/"hybrid" registry it embeds in one batched, off-thread pass (errors surface when awaited); "bm25" has nothing to embed. Always `await` the result. #### replace\_all ```python replace_all(skills: Iterable[Skill]) -> PendingReplace ``` Make `skills` the entire corpus — see `SkillCatalog.replace_all`. The corpus swap lands **synchronously** (a forgotten `await` never leaves a half-applied reload); the returned `PendingReplace` already carries the counts and drives only the embedding pass when awaited. Always `await` the result. #### search ```python search(query: str, top_k: int) -> list[SkillHit] ``` Run synchronous, model-free BM25 retrieval. #### search\_async ```python async search_async(query: str, top_k: int, origin: SearchOrigin = 'direct', method: SearchMethod = 'bm25') -> list[SkillHit] ``` Search immediately with BM25 or run dense retrieval on a worker thread. #### search\_with\_method ```python search_with_method(query: str, top_k: int, origin: SearchOrigin, method: SearchMethod) -> list[SkillHit] ``` Run BM25 synchronously; dense retrieval is async-only. #### search\_with\_origin ```python search_with_origin(query: str, top_k: int, origin: SearchOrigin) -> list[SkillHit] ``` Run BM25 retrieval with an explicit trace origin. #### set\_trace\_sink ```python set_trace_sink(kind: str, session_id: str | None = None, path: str | None = None) -> None ``` Replace the native trace sink. ### Tool ```python Tool(id: str, name: str, description: str, input_schema: dict[str, Any] = dict(), output_schema: dict[str, Any] = dict()) ``` Tool metadata: what the index ranks and the capability tools surface. **Attributes:** * `description: str` * `id: str` * `input_schema: dict[str, Any] = field(default_factory=dict)` * `name: str` * `output_schema: dict[str, Any] = field(default_factory=dict)` ### ToolCatalog ```python ToolCatalog(trace: TraceSinkConfig | None = None, method: SearchMethod = 'bm25', embedding: EmbeddingSpec | None = None) ``` Registry + executors. Register tools once, then search and invoke by id. Create an empty catalog. **Parameters:** * `trace` (`TraceSinkConfig | None`) — where trace events go; `None` keeps the default no-op sink. * `method` (`SearchMethod`) — default retrieval method for `search` — "bm25" (the historical, model-free behavior), "semantic" or "hybrid". A per-call `method=` overrides it. A "semantic"/"hybrid" catalog embeds inside `register`; dense results come from `search_async`. * `embedding` (`EmbeddingSpec | None`) — model for semantic/hybrid retrieval (a path string or a keyed dict — see `EmbeddingSpec`). Retained and validated even under "bm25" so a later async semantic override can use it. **Attributes:** * `experimental_adaptive_ranking_status: AdaptiveRankingStatus` — Adaptive-ranking status: active, inactive, unknown, or paused. #### drain\_trace\_events ```python drain_trace_events() -> list[dict[str, Any]] ``` Drain captured trace envelopes; `[]` unless the sink is "memory". #### experimental\_disable\_adaptive\_ranking ```python experimental_disable_adaptive_ranking() -> None ``` Turn adaptive usage ranking off; the graph keeps what it learned. #### experimental\_enable\_adaptive\_ranking ```python experimental_enable_adaptive_ranking(graph: IntentGraph, *, warn_on_model_mismatch: bool = True, rebuild_on_model_change: bool = False) -> None ``` Turn on adaptive usage ranking against `graph` (ADR-0014). Wires both halves: this catalog ranks against what users have actually invoked after similar queries, and keeps learning as it is used. Pass the same :class:`IntentGraph` to the other catalog so both learn into one set of clusters. Only queries matching a cluster are affected. With a graph attached the hit `score` becomes a fusion score rather than a raw BM25 score, so use `rank` for ordering and `fused` to detect the scale, not the raw `score`. Set `rebuild_on_model_change` to auto-recover a model-mismatched graph on the next dense search rather than staying paused until you call :meth:`experimental_rebuild_intent_graph` yourself. Off by default — the rebuild is an embedding pass (cost, possible :class:`EmbedderError`, mutates the graph). #### experimental\_rebuild\_intent\_graph ```python async experimental_rebuild_intent_graph() -> None ``` Re-embed the graph's members under the current model; preserves learning. #### get ```python get(tool_id: str) -> Tool | None ``` Return the metadata-only `Tool` for an id, or `None` if unknown. #### get\_executable ```python get_executable(tool_id: str) -> ExecutableTool | None ``` Return the `ExecutableTool` (metadata plus handler) for an id, or `None`. #### has ```python has(tool_id: str) -> bool ``` Return whether a tool with this id is registered. #### invoke ```python async invoke(tool_id: str, args: dict[str, Any]) -> Any ``` Run a registered tool's handler and return its result. This is the canonical place that absorbs the sync/async executor difference: the handler is called first and the result awaited only if it is awaitable, so plain functions and `async def` executors (e.g. MCP/HTTP tools) are both supported. Callers must route invocations here rather than re-deriving that logic. Emits `invoke_start` / `invoke_end` / `invoke_error` trace events and wraps the call in an `execute_tool` OTel span (ADR-0007). **Parameters:** * `tool_id` (`str`) — id of a registered tool. * `args` (`dict[str, Any]`) — the arguments dict passed to the handler. **Returns:** `Any` — Whatever the handler returns (awaited if it returned an awaitable). **Raises:** * `ValueError` — if `tool_id` is not registered. * `Exception` — whatever the handler raises, re-raised after an `invoke_error` trace event is recorded. #### record\_event ```python record_event(event: dict[str, Any]) -> None ``` Record a trace event into the catalog's sink. **Parameters:** * `event` (`dict[str, Any]`) — a dict matching one of the core-owned `TraceEvent` shapes (ADR-0007), e.g. `{"type": "gateway_search", ...}`. **Raises:** * `ValueError` — if the dict doesn't match any known event shape. #### register ```python register(tools: ExecutableTool | Iterable[ExecutableTool]) -> Awaitable[None] ``` Register one tool or many — the single entry point for both. Metadata and the executor handler are stored **synchronously**, the instant `register(...)` is called, so a forgotten `await` can never silently drop the corpus. The returned awaitable drives only the embedding pass: on a "semantic"/"hybrid" catalog it embeds the batch in one pass on a worker thread (the event loop is never blocked), and embedding errors (model load / endpoint / auth / dimension) surface when it is awaited. A BM25 catalog never loads a model and the awaitable is a no-op. **Always `await` the result** — a semantic/hybrid search after an un-awaited `register` raises rather than ranking an empty corpus. Re-registering an id replaces it in place; the index never holds a duplicate. A model or dimension change is not recovered in place — construct a new catalog and re-register. **Parameters:** * `tools` (`ExecutableTool | Iterable[ExecutableTool]`) — a single `ExecutableTool` or an iterable of them; each `execute` must be set. Pass the whole batch at once for a single embedding request; separate `register` calls embed separately. **Raises:** * `ValueError` — if any `execute` is `None`, or a schema isn't JSON-serializable. * `EmbedderError` — on a semantic/hybrid catalog, if embedding fails (when awaited). * `RuntimeError` — if a dense operation already owns the registry. #### search ```python search(query: str, top_k: int, origin: SearchOrigin = 'direct', method: SearchMethod | None = None) -> list[SearchHit] ``` Rank registered tools synchronously with BM25. **Parameters:** * `query` (`str`) — what the caller wants to do. * `top_k` (`int`) — max hits to return. * `origin` (`SearchOrigin`) — who initiated the search — labels the trace event only. * `method` (`SearchMethod | None`) — per-call override of the catalog's default retrieval method ("bm25" | "semantic" | "hybrid"). **Returns:** `list[SearchHit]` — Up to `top_k` `SearchHit`s, best first. **Raises:** * `ValueError` — if `method` is not "bm25", "semantic" or "hybrid". * `RuntimeError` — if the resolved method is semantic/hybrid; use `search_async` for dense retrieval. #### search\_async ```python async search_async(query: str, top_k: int, origin: SearchOrigin = 'direct', method: SearchMethod | None = None) -> list[SearchHit] ``` Rank tools asynchronously with BM25, semantic, or hybrid retrieval. Dense methods require the corpus to have been embedded by `register` on a semantic/hybrid catalog; searching never embeds missing corpus vectors. ### ToolRegistry ```python ToolRegistry(embedding: EmbeddingSpec | None = None, *, method: SearchMethod = 'bm25', spec: str | None = None, huggingface: str | None = None, local: str | None = None, ollama: str | None = None, url: str | None = None, model: str | None = None, revision: str | None = None, api_key_env: str | None = None, query_prefix: str | None = None, doc_prefix: str | None = None, pooling: str | None = None, download: bool | None = None) ``` Typed Python facade over the private native tool registry. Create a metadata registry with an optional embedding model. A "semantic"/"hybrid" `method` makes `register` embed eagerly (inside the call, on a worker thread); "bm25" keeps registration model-free. **Attributes:** * `experimental_adaptive_ranking_status: AdaptiveRankingStatus` — Adaptive-ranking status; a str that also carries a pause's model detail. #### drain\_trace\_events ```python drain_trace_events() -> list[dict[str, Any]] ``` Drain captured native trace events. #### experimental\_disable\_adaptive\_ranking ```python experimental_disable_adaptive_ranking() -> None ``` Turn adaptive usage ranking off; the graph keeps what it learned. #### experimental\_enable\_adaptive\_ranking ```python experimental_enable_adaptive_ranking(graph: IntentGraph, *, warn_on_model_mismatch: bool = True, rebuild_on_model_change: bool = False) -> None ``` Turn on adaptive usage ranking against `graph` (ADR-0014). Wires both halves: this registry ranks against what users have actually invoked after similar queries, and keeps learning as it is used. Pass the same :class:`IntentGraph` to the other registry so both learn into one set of clusters. Only queries matching a cluster are affected. With a graph attached the hit `score` becomes a fusion score rather than a raw BM25 score, so use `rank` for ordering and `fused` to detect the scale, not the raw `score`. On a model change the arm pauses and a one-time warning is issued unless `warn_on_model_mismatch` is False; call :meth:`experimental_rebuild_intent_graph`. Set `rebuild_on_model_change` to recover automatically instead: the next dense (semantic/hybrid) search re-embeds the graph under the current model before searching, so a persisted graph self-heals after a model swap. It is off by default because the rebuild is an embedding pass — expensive, able to raise :class:`EmbedderError`, and it mutates the graph (new centroids, bumped `rev`). Recovery is lazy: status stays `paused` until that first dense search. #### experimental\_rebuild\_intent\_graph ```python async experimental_rebuild_intent_graph() -> None ``` Re-embed the graph's members under the current model; preserves learning. Call after changing the embedding model: a graph's centroids are only comparable to queries from the model that built them, so on a swap the usage arm pauses until this runs. #### record\_event ```python record_event(event: dict[str, Any]) -> None ``` Record an SDK-layer trace event. #### register ```python register(item: Tool | Iterable[Tool] | str, name: str | None = None, description: str | None = None, input_schema: dict[str, Any] | None = None, output_schema: dict[str, Any] | None = None) -> Awaitable[None] ``` Register one `Tool`, many `Tool`s, or a flat (id, name, …) tuple. Metadata is indexed **synchronously**, the instant `register(...)` is called, so a forgotten `await` can never silently drop the corpus. The returned awaitable drives only the embedding pass — on a "semantic"/"hybrid" registry it embeds in one batched, off-thread pass (embedding errors surface when awaited); "bm25" has nothing to embed and the awaitable is a no-op. Always `await` the result. #### search ```python search(query: str, top_k: int) -> list[SearchHit] ``` Run synchronous, model-free BM25 retrieval. #### search\_async ```python async search_async(query: str, top_k: int, origin: SearchOrigin = 'direct', method: SearchMethod = 'bm25') -> list[SearchHit] ``` Search immediately with BM25 or run dense retrieval on a worker thread. #### search\_with\_method ```python search_with_method(query: str, top_k: int, origin: SearchOrigin, method: SearchMethod) -> list[SearchHit] ``` Run BM25 synchronously; dense retrieval is async-only. #### search\_with\_origin ```python search_with_origin(query: str, top_k: int, origin: SearchOrigin) -> list[SearchHit] ``` Run BM25 retrieval with an explicit trace origin. #### set\_trace\_sink ```python set_trace_sink(kind: str, session_id: str | None = None, path: str | None = None) -> None ``` Replace the native trace sink. ### TraceSinkConfig ```python TraceSinkConfig(kind: str, session_id: str | None = None, path: str | None = None) ``` Where the catalog's trace events go. Mirrors the TS `TraceSinkConfig` union. kind: "noop" | "memory" | "jsonl". `session_id` is required for memory/jsonl; `path` is required for jsonl. **Attributes:** * `kind: str` * `path: str | None = None` * `session_id: str | None = None` ### UpstreamServerInfo ```python UpstreamServerInfo(name: str, description: str | None = None, instructions: str | None = None, tool_count: int | None = None, needs_auth: bool = False) ``` An upstream MCP server, as advertised in the capability-tool descriptions. Pass a list of these to `search_capabilities_tool` (or the deprecated `search_tools_tool`) to append a "this catalog aggregates…" listing to the tool description shown to the model, and to enrich each search result's server group with the upstream's description and instructions. **Attributes:** * `name` (`str`) — the catalog namespace of the server — the `` prefix of its `__` tool ids. * `description` (`str | None`) — what the server offers; compacted to one line in listings. * `instructions` (`str | None`) — the server's usage instructions (from its MCP `initialize` result), surfaced on matching server groups. * `tool_count` (`int | None`) — number of tools the server contributed, shown in listings. * `needs_auth` (`bool`) — True when the upstream rejected its boot connection with a 401 / re-auth needed; listings append "(auth required)". ## Functions ### configure\_telemetry ```python configure_telemetry(*, api_key: str | None = None, endpoint: str | None = None, logs_endpoint: str | None = None, headers: dict[str, str] | None = None, service_name: str | None = None, capture_content: ContentCapture | str | None = None, include_span_and_events: bool | None = None, export_all_spans: bool = False) -> Any ``` Register Ratel-owned OTLP trace and Logs exporters for the greenfield case. Ships the spans and EventRecords this SDK emits to Ratel Cloud (or any OTLP endpoint) by delegating to `ratel_ai_telemetry.init`, which needs the OpenTelemetry SDK — install it with `pip install 'ratel-ai[otlp]'`. A host already running its own OpenTelemetry providers should skip this (SDK telemetry flows to them) and add both `ratel_span_processor` and `ratel_log_record_processor`. `capture_content` / `include_span_and_events` opt into message/tool content capture in code via `set_content_capture`: `capture_content` sets an exact mode, `include_span_and_events` is bool sugar (True -> `SPAN_AND_EVENT`, False -> `NO_CONTENT`); `capture_content` wins when both are given. A provided option beats `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` (the env var is the fallback when neither is given, as in OTel). The returned handle's `shutdown()` clears the override, restoring env-driven behavior; the clear is generation-scoped, so a stale handle shutting down late never clobbers an override a newer `configure_telemetry`/`set_content_capture` call installed. **Parameters:** * `api_key` (`str | None`) — Ratel Cloud API key override; defaults to `RATEL_API_KEY`. * `endpoint` (`str | None`) — OTLP endpoint override; defaults to `RATEL_OTLP_ENDPOINT`, then the superseded `RATEL_URL` (which warns). * `logs_endpoint` (`str | None`) — OTLP logs endpoint override; defaults to the sibling `/v1/logs` URL derived from `endpoint`. * `headers` (`dict[str, str] | None`) — Extra headers sent with every export request. * `service_name` (`str | None`) — `service.name` resource attribute; defaults per `init`. * `capture_content` (`ContentCapture | str | None`) — Exact content-capture mode to set (see above). * `include_span_and_events` (`bool | None`) — Boolean sugar for `capture_content` (see above). * `export_all_spans` (`bool`) — Export every span, not just the `gen_ai.*`/`ratel.*` signal. Default False: this high-level path defaults to `ratel_signal_filter` so unrelated HTTP/database/application spans are not shipped to Ratel (privacy + cost). Set True to forward all spans. **Returns:** `Any` — A per-call shutdown handle (`handle.shutdown()` / `handle.force_flush()`), the same shape on every path. Attribute access delegates to the shared handle `init` returns; because that handle is shared across callers, shutting it down stops export for all of them. **Raises:** * `ModuleNotFoundError` — if the OpenTelemetry exporter is not installed (`pip install 'ratel-ai[otlp]'`). * `ValueError` — if `capture_content` is not a recognized mode — raised before any exporter is wired, so a bad option has no side effects. ### format\_upstream\_line ```python format_upstream_line(s: UpstreamServerInfo) -> str ``` Render one upstream server as a listing bullet for a tool description. Format: `- — ( tools) (auth required)`, where the description is whitespace-collapsed and truncated to \~160 chars, and each trailing part appears only when the corresponding field is set. **Parameters:** * `s` (`UpstreamServerInfo`) — the upstream server to render. **Returns:** `str` — A single `- `-prefixed line, ready to join into the "this catalog aggregates…" listing. ### get\_skill\_content\_tool ```python get_skill_content_tool(catalog: SkillCatalog) -> ExecutableTool ``` Build the `get_skill_content` tool: load a skill's full body by id. The returned tool resolves `skillId` and answers `{"body": }` via `SkillCatalog.invoke` (which records a `skill_invoke` trace event). It never raises into the host: an unknown or non-string id comes back as a structured `{"error": ..., "isError": True}` payload the model can recover from, mirroring `invoke_tool`. **Parameters:** * `catalog` (`SkillCatalog`) — the skill catalog to load bodies from. **Returns:** `ExecutableTool` — An `ExecutableTool` to put in the agent's direct tool list alongside `search_capabilities`. ### invoke\_tool\_tool ```python invoke_tool_tool(catalog: ToolCatalog, *, on_unauthorized: OnUnauthorized | None = None) -> ExecutableTool ``` Build the `invoke_tool` tool: run any catalog tool by id. The returned tool resolves `toolId`, forwards the nested `args` object to `ToolCatalog.invoke`, and never raises into the host: an unknown id, malformed `args`, or a tool exception all come back as a structured `{"error": ..., "isError": True}` payload the model can recover from. A flattened call (arguments at the top level instead of nested under `args`) is tolerated. When the tool raises an `UnauthorizedError`, the payload is `{"error": "needs_auth", ...}` with a re-auth hint and, for namespaced `__` ids, the upstream server name. **Parameters:** * `catalog` (`ToolCatalog`) — the catalog to resolve and run tool ids against. * `on_unauthorized` (`OnUnauthorized | None`) — called with the upstream server name when a tool raises `UnauthorizedError`; may be sync or async. Skipped when the tool id has no `__` namespace prefix. **Returns:** `ExecutableTool` — An `ExecutableTool` to put in the agent's direct tool list. ### register\_mcp\_server ```python async register_mcp_server(catalog: ToolCatalog, *, name: str, session: Any, transport_label: str = 'unknown', instructions: str | None = None, on_close: Callable[[], Awaitable[None]] | None = None) -> McpServerHandle ``` Ingest an initialized MCP `ClientSession` into the catalog. **Parameters:** * `catalog` (`ToolCatalog`) — the catalog to register the upstream tools into. * `name` (`str`) — namespace prefix for tool ids (`__`). * `session` (`Any`) — an initialized `mcp.ClientSession` owned by the caller. * `transport_label` (`str`) — recorded on the `upstream_register` trace event. * `instructions` (`str | None`) — the upstream's server instructions (from `initialize`), if any. * `on_close` (`Callable[[], Awaitable[None]] | None`) — optional async teardown invoked by the handle's `close()`. **Returns:** `McpServerHandle` — An `McpServerHandle` with the registered tool ids. **Raises:** * `ImportError` — if the optional `mcp` package is not installed (`pip install 'ratel-ai[mcp]'`). * `McpToolsListError` — if `tools/list` repeats a cursor or exceeds the page cap. ### search\_capabilities\_tool ```python search_capabilities_tool(catalog: ToolCatalog, skill_catalog: SkillCatalog | None = None, *, upstream_servers: Sequence[UpstreamServerInfo] | None = None) -> ExecutableTool ``` Build the `search_capabilities` tool: unified discovery over tools AND skills. The returned tool ranks two independent buckets, each with its own top-K budget — so a relevant skill is never starved out of the results by a large number of matching tools (and the two BM25 corpora are never score-compared). Tools land grouped by upstream server; a matched skill's declared tools are pulled into the tools bucket additively (score 0), so the agent gets the playbook and its toolkit in one turn. The `skills` bucket (and the mention of `get_skill_content` in the description) is only advertised when a non-empty `skill_catalog` is wired in. **Parameters:** * `catalog` (`ToolCatalog`) — the tool catalog to search. * `skill_catalog` (`SkillCatalog | None`) — optional skill catalog, ranked against the same query in its own bucket. * `upstream_servers` (`Sequence[UpstreamServerInfo] | None`) — upstream MCP servers to advertise in the tool description and to enrich result server groups with. **Returns:** `ExecutableTool` — An `ExecutableTool` to put in the agent's direct tool list. ### search\_tools\_tool ```python search_tools_tool(catalog: ToolCatalog, *, upstream_servers: Sequence[UpstreamServerInfo] | None = None) -> ExecutableTool ``` Build the pre-0.2.0 tools-only discovery tool (id `search_tools`). Keeps the original behaviour: a flat `{groups}` result and no skills bucket. New code should use `ratel_ai.search_capabilities_tool` instead. Registering both lets a host serve the old and new names during a migration window. **Parameters:** * `catalog` (`ToolCatalog`) — the tool catalog to search. * `upstream_servers` (`Sequence[UpstreamServerInfo] | None`) — upstream MCP servers to advertise in the tool description and to enrich result server groups with. **Returns:** `ExecutableTool` — An `ExecutableTool` to put in the agent's direct tool list. > **Deprecated** — Since 0.2.0: use `ratel_ai.search_capabilities_tool`. Tracked for > removal in RAT-250. ## Constants * `GET_SKILL_CONTENT_ID = 'get_skill_content'` — Id (and name) of the skill-loading tool built by `get_skill_content_tool`. * `INVOKE_TOOL_ID = 'invoke_tool'` — Id (and name) of the invocation tool built by `invoke_tool_tool`. * `SEARCH_CAPABILITIES_ID = 'search_capabilities'` — Id (and name) of the discovery tool built by `search_capabilities_tool`. * `SEARCH_TOOLS_ID = 'search_tools'` — Id (and name) of the deprecated pre-0.2.0 discovery tool built by `search_tools_tool`; superseded by `ratel_ai.SEARCH_CAPABILITIES_ID`. * `EmbeddingModelConfig = Union[HuggingFaceEmbeddingConfig, LocalEmbeddingConfig, OllamaEmbeddingConfig, EndpointEmbeddingConfig]` — Mutually exclusive keyed embedding-source configurations. * `EmbeddingSpec = Union[str, EmbeddingModelConfig]` — Embedding selection; a bare string is a local model directory path. * `Executor = Callable[[dict[str, Any]], Union[Awaitable[Any], Any]]` — A tool handler: takes the tool's arguments dict, returns the result. May be sync or async (tool inputs are heterogeneous across the catalog); `ToolCatalog.invoke` absorbs the difference. * `OnUnauthorized = Callable[[str], Union[Awaitable[None], None]]` — Notified when the underlying tool raises `UnauthorizedError`, with the upstream server name inferred from the toolId. May be sync or async. * `SearchMethod = str` — Retrieval engine: `"bm25"` (lexical, model-free, the default), `"semantic"` (dense embeddings) or `"hybrid"` (both, fused). * `SearchOrigin = str` — Who initiated a search: `"direct"` (host code, the default) or `"agent"` (a model calling a capability tool). Labels the emitted trace event only — ranking is unaffected. --- # Changelog Source: https://www.ratel.sh/docs/packages/python/ratel-ai/changelog > ratel-ai release history · [PyPI](https://pypi.org/project/ratel-ai/) · [source](https://github.com/ratel-ai/ratel/tree/main/src/sdk/python) > > Synced verbatim from the package's `CHANGELOG.md` at `ratel-ai/ratel@d8635d5` — do not edit by hand. Regenerate with `pnpm sync:api` in `apps/docs`. All notable changes to `ratel-ai` (the Python SDK) are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this package adheres to [Semantic Versioning](https://semver.org/). ## \[0.7.0] - 2026-08-07 ### Added * **Whole-catalog skill reload: `SkillCatalog.replace_all` / `SkillRegistry.replace_all` (ADR-0015).** For a source that fetches the full skill catalog rather than individual changes — the batch *is* the catalog, so ids missing from it are removed, including ones registered in-process. Two-phase like `register`: the corpus swap lands **synchronously** (a forgotten `await` never leaves a half-applied reload) and only the embedding pass is awaited, so a reload whose embedding pass fails still reports what the swap changed. Returns a `PendingReplace` carrying the final `added` / `removed` / `updated` / `unchanged` counts; awaiting it drives the embedding pass and resolves to a plain `ReplaceOutcome`. Always `await` the result so an embedding failure raises rather than being swallowed. On failure the new corpus is live and BM25 ranks it while semantic search reports not-built until a later pass succeeds; a reload started while a dense operation owns the registry is rejected rather than blended, and reloading an unchanged catalog costs zero embeddings. `PendingReplace` and `ReplaceOutcome` are exported from `ratel_ai`. * **`register_mcp_server` follows every `tools/list` page.** Ingestion previously read only the first page, silently dropping every tool past it on a paginated server. It now walks `nextCursor` to exhaustion (treating `""` as a valid cursor, per MCP — only an absent `nextCursor` ends pagination) across `mcp` client versions before and after 1.18, capped at 64 pages. `McpToolsListError` (with a stable `code` of `"RepeatedCursor"` or `"PaginationExceeded"`) is exported from `ratel_ai`, so a cursor loop or a runaway server is a typed failure rather than a hang. `McpServerHandle.tool_ids` now spans all pages in upstream list order. ### Fixed * Telemetry tool-result capture records only the stable `CallToolResult` fields (`content`, `structuredContent`, `isError`). It previously dumped the whole model, so fields the `mcp` package adds or renames between versions leaked into captured content and made the recorded payload depend on the installed client version. ## \[0.6.0] - 2026-07-28 > **Coming from `0.6.0rc0`?** That RC was tagged off a branch that predated 0.5.2, so it > shipped neither the `EventRecord` content-capture fixes nor the base `ratel-ai-telemetry` > runtime dependency. Read the 0.5.2 entry below as part of this upgrade. Upgrades from > 0.5.2 are unaffected: for them this release is purely additive. ### Added * **Experimental adaptive usage ranking (ADR-0014).** `IntentGraph` plus `experimental_enable_adaptive_ranking`, `experimental_rebuild_intent_graph`, `experimental_disable_adaptive_ranking`, and `experimental_adaptive_ranking_status` on `ToolCatalog` / `SkillCatalog`. The catalog learns from each search-then-invoke and boosts future rankings; persist and reload via `IntentGraph.to_json` / `from_json`, and track writes via `graph.rev`. Shipped behind an `experimental_` prefix — the API may change until it graduates. * `rank` and `fused` on search hits: order on `rank`, and branch on `fused` to know whether the usage arm changed the ranking. * Opt-in recovery after an embedding-model change: `experimental_enable_adaptive_ranking(graph, rebuild_on_model_change=True)` re-embeds a paused graph on the next dense search. Default off; explicit `experimental_rebuild_intent_graph()` otherwise. `experimental_adaptive_ranking_status` returns an `AdaptiveRankingStatus` that carries the paused/active state and the mismatched-model detail. ## \[0.5.2] - 2026-07-26 ### Changed * `configure_telemetry(endpoint=...)` now defaults to `RATEL_OTLP_ENDPOINT`, falling back to the superseded `RATEL_URL` with a `DeprecationWarning`. `RATEL_URL` also selects the catalog source (ADR-0003), so it no longer doubles as the OTLP destination. Resolution lives in `ratel-ai-telemetry>=0.1.3`; nothing breaks here, a `RATEL_URL`-only install keeps exporting to the same endpoint. ### Fixed * Emit content capture as structured OpenTelemetry Logs `EventRecord`s, keep tool results out of inference-output messages, and export those records through `configure_telemetry()`. * Serialize real MCP `CallToolResult` values into telemetry instead of recording an empty string. * Preserve heterogeneous JSON arrays losslessly despite OpenTelemetry Python 1.41's homogeneous-array limitation. * Ship and require the OTel-free telemetry vocabulary version that defines the EventRecord contract, so base installs emit into host-owned OpenTelemetry providers. ## \[0.5.1] - 2026-07-24 ### Changed * Ship the native extension built with symbol stripping and thin LTO (`[profile.release]`), materially shrinking the wheel's compiled binary. No API or behavior change. ## \[0.5.0] - 2026-07-20 ### Added * `register()` accepts a single item or an iterable across tool/skill registries and catalogs. * Configurable default, HuggingFace, local Candle, Ollama, and OpenAI-compatible endpoint embedding sources, with public `EmbeddingSpec`, `EmbeddingModelConfig`, and source-specific `TypedDict` variants. ### Changed * **BREAKING:** `register()` now accepts a single tool/skill **or an iterable of them** and folds embedding in, returning an awaitable (`Awaitable[None]`): on a `"semantic"`/`"hybrid"` catalog it embeds the batch off the asyncio loop (GIL released), so embedding errors (model load / endpoint / auth / dimension) surface from `await register(...)`. A `"bm25"` catalog registers metadata only and never loads a model. `search()` stays synchronous BM25-only; `search_async()` covers BM25/semantic/hybrid. There is **no** `register_many()`, `build_embeddings()`, or `rebuild_embeddings()` — `register()` embeds, and recovery from a model/dimension change is to construct a new catalog and re-register. * Capability tools await async retrieval; MCP ingestion embeds ingested tools during `register`. * Embedding configuration is validated and retained on BM25-default catalogs for later async semantic/hybrid overrides; typed config variants are mutually exclusive. ### Fixed * A forgotten `await` on `register()` no longer silently drops the corpus: an un-awaited call still registers the tools/skills (BM25 keeps working), and a `"semantic"`/`"hybrid"` `search_async` after an un-awaited `register()` raises an actionable "not awaited" error instead of ranking an empty corpus. ## \[0.4.2] - 2026-07-11 ### Changed * `configure_telemetry()` returns a per-call shutdown handle (`handle.shutdown()` / `handle.force_flush()`) on every path — the no-override path no longer leaks `init()`'s shared provider directly. Because that provider is shared across callers, shutting one handle down stops export for all of them. ### Fixed * `configure_telemetry()` no longer mutates a shared provider's `shutdown` method. When idempotent telemetry initialization reuses one provider, a stale handle can no longer clear a newer caller's generation-scoped content-capture override. ## \[0.4.1] - 2026-07-10 ### Added * `configure_telemetry` opts into message/tool content capture programmatically: `capture_content` sets the exact `ContentCapture` mode (validated like the env var — case-insensitive, legacy boolean forms accepted — raising a `ValueError` on garbage before any exporter is wired), and `include_span_and_events` is boolean sugar (`True` → `SPAN_AND_EVENT`, `False` → `NO_CONTENT`). `capture_content` wins over `include_span_and_events`; when neither is provided, `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` keeps ruling (a provided option beats the env var, as in OTel code-over-env precedence). The returned provider's `shutdown()` restores env-driven behavior via a generation-scoped clear (`clear_content_capture`), so a stale handle shutting down late never clobbers an override a newer `configure_telemetry` installed. The `set_content_capture` / `clear_content_capture` / `ContentCapture` primitives live in `ratel_ai_telemetry`. ## \[0.4.0] - 2026-07-07 ### Added * **OpenTelemetry emission.** The SDK now opens an OTel span at each funnel boundary — `execute_tool` (`gen_ai.operation.name`, `gen_ai.tool.name`, `ratel.tool.args_size_bytes`), `ratel.search` (target, `top_k`, origin, `hit_count`), `ratel.skill.load`, `ratel.upstream.register`, and `ratel.auth.flow` — alongside the existing local `record_event` stream, which is unchanged. Emission is transparent and free by default: the `opentelemetry` API and the vocabulary are imported lazily, so the base (dependency-free) install is a pure pass-through no-op, and when OpenTelemetry is present the spans flow to whatever provider is registered. Built on the OTel-free `ratel_ai_telemetry` vocabulary, so the base install stays dependency-free. * `configure_telemetry(api_key=..., endpoint=..., headers=..., service_name=...)` convenience wiring, exported from `ratel_ai`: installs a Ratel-owned OTLP exporter (via the new `[otlp]` extra, `pip install 'ratel-ai[otlp]'`) that ships the SDK's spans to Ratel Cloud (or any OTLP endpoint) and returns the provider as a shutdown handle. Hosts already running OpenTelemetry skip it and add `ratel_span_processor` from `ratel_ai_telemetry` instead. * Message/tool content (`ratel.search.query`, `gen_ai.tool.call.arguments` / `.result`) rides span attributes only when `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` selects a span mode (`SPAN_ONLY` / `SPAN_AND_EVENT`); default off. ## \[0.3.0] - 2026-07-06 ### Added * `ToolCatalog` / `SkillCatalog` accept a default `method` (`"bm25"` | `"semantic"` | `"hybrid"`) and `search(query, top_k, origin=..., method=...)` takes a per-call override. `"bm25"` (default) is unchanged and model-free; `"semantic"` / `"hybrid"` load a local embedding model and raise `RuntimeError` if it fails to load. Exposed via the native `search_with_method` binding and the `SearchMethod` type. * A `"semantic"`/`"hybrid"` catalog embeds each tool/skill **eagerly at `register`** (incrementally), so searches never pay the corpus-embedding cost. New `catalog.build_embeddings()` pre-computes embeddings on demand (e.g. after a bulk register). BM25 catalogs do neither. A semantic/hybrid search on a catalog with no embeddings built raises `RuntimeError` (embeddings not computed) rather than embedding during the search. ## \[0.2.1-rc.1] - 2026-07-04 ### Changed * First release cut under the per-package release scheme (ADR-0008): `ratel-ai` now versions and ships independently of the core crate and JS SDK, tagged `sdk-py-v*`. No API changes since 0.2.0. ## \[0.2.0] - 2026-06-16 ### Changed * **BREAKING:** `search_tools_tool` → `search_capabilities_tool` (`SEARCH_TOOLS_ID` → `SEARCH_CAPABILITIES_ID`). It now returns two independently-ranked buckets — `{ tools, skills }`. Brings the Python SDK to parity with the TypeScript SDK. The old `search_tools_tool`/`SEARCH_TOOLS_ID` are kept as deprecated aliases (see *Deprecated*), so `0.1.x` code keeps working after upgrading. ### Added * First-class **skills**: `SkillCatalog`, `Skill`, `get_skill_content_tool` (`get_skill_content`), and the native `SkillRegistry`/`SkillHit` — the on-demand skill analogue of the tool catalog, ranked by a separate BM25 corpus. * Skill–tool coupling: a `Skill` can declare a `tools` list, and `search_capabilities` pulls a matched skill's declared tools into the `tools` bucket — additively (beyond `topKTools`) and deduped against query hits — so the agent gets the playbook and the tools it needs in one turn. ### Deprecated * `search_tools_tool` and `SEARCH_TOOLS_ID`. They retain their pre-0.2.0 behaviour — a tools-only `{groups}` result and the `search_tools` id — so upgrading from `0.1.x` does not break existing callers. Migrate to `search_capabilities_tool`; the aliases will be removed in a future release. ### Fixed * Gateway error payloads carry `isError: True`; `invoke_tool` rejects a non-object `args` instead of forwarding stray top-level keys. * `search_capabilities_tool` validates `topKTools`/`topKSkills` (declared `integer`, positive): `0`, negative, `bool`, and `float` fall back to the default, matching the TypeScript SDK exactly. * `search_capabilities_tool` advertises the `skills` bucket and `get_skill_content` in its description only when a non-empty `SkillCatalog` is provided. ## \[0.1.6] - 2026-06-10 ### Added * Initial release of the Python SDK. Binds the Rust core (`ratel-ai-core`) via PyO3, distributed as prebuilt `abi3` wheels for darwin-arm64, darwin-x64, linux-x64-gnu, linux-arm64-gnu, and win32-x64-msvc — no Rust toolchain required to install. (`v0.1.5` shipped TS-only on 2026-05-10; the first release carrying Python is the next version bump.) Binding strategy locked in [ADR-0006](https://github.com/ratel-ai/ratel/blob/main/docs/adr/0006-native-ffi-bindings.md). * Full feature parity with the TypeScript SDK (`@ratel-ai/sdk`): * `ToolRegistry` / `SearchHit` — metadata-only BM25 index (native). * `ToolCatalog` accepts a `trace` config (`noop` default, `memory`, or `jsonl`); captured events flow through the Rust core sink ([ADR-0007](https://github.com/ratel-ai/ratel/blob/main/docs/adr/0007-telemetry-two-streams.md)). Exposes `record_event`, `drain_trace_events`, and an `origin` argument on `search`. `invoke` emits `invoke_start` / `invoke_end` / `invoke_error` with `args_size_bytes` and `took_ms`, and awaits coroutine executors. * `search_tools_tool` / `invoke_tool_tool` gateway factories with verbatim descriptions and JSON schemas from the TS SDK. `search_tools_tool` emits `gateway_search` with `origin: "agent"`; `invoke_tool_tool` emits `gateway_invoke` / `gateway_error` and handles the `needs_auth` / `on_unauthorized` path. * `register_mcp_server` ingests an upstream MCP `ClientSession` (optional `mcp` extra, `pip install 'ratel-ai[mcp]'`), namespacing tool ids as `__` and emitting `upstream_register` / `upstream_invoke` / `upstream_error`. * Ships type stubs (`_native.pyi`, `py.typed`) for a fully typed install. --- # ratel-ai-telemetry (Python) Source: https://www.ratel.sh/docs/packages/python/ratel-ai-telemetry > v0.1.3 · Python · [PyPI](https://pypi.org/project/ratel-ai-telemetry/) · [source](https://github.com/ratel-ai/ratel/tree/main/src/telemetry/python) > > Generated from the doc comments at `ratel-ai/ratel@d8635d5` — do not edit by hand. Regenerate with `pnpm sync:api` in `apps/docs`. ## Classes ### AuthOutcome Outcome of an MCP auth flow. Carried by ratel.auth.outcome; NEEDS\_AUTH is the 401-driven AuthNeeds case (ADR-0007 auth\_needs). **Attributes:** * `FAILED = 'failed'` * `NEEDS_AUTH = 'needs_auth'` * `OK = 'ok'` * `REFRESHED = 'refreshed'` ### Origin Whether a ratel.\* span was a direct library call or synthesized by the agent inside its loop. Carried by ratel.origin; mirrors the local trace Origin (ADR-0007). A str-Enum: each member equals its exact wire string, so it is usable directly as an OTel attribute value. **Attributes:** * `AGENT = 'agent'` * `DIRECT = 'direct'` ### SearchTarget What a ratel.search span was searching. Carried by ratel.search.target; folds capability-tool search and skill search into one span shape. **Attributes:** * `SKILL = 'skill'` * `TOOL = 'tool'` ### ContentCapture Message/tool content capture modes for OTEL\_INSTRUMENTATION\_GENAI\_CAPTURE\_MESSAGE\_CONTENT (CONVENTIONS.md § Capture gating). Default off. **Attributes:** * `EVENT_ONLY = 'EVENT_ONLY'` * `NO_CONTENT = 'NO_CONTENT'` * `SPAN_AND_EVENT = 'SPAN_AND_EVENT'` * `SPAN_ONLY = 'SPAN_ONLY'` ### OtlpConfig ```python OtlpConfig(url: str, logs_url: str, headers: dict[str, str], service_name: str) ``` Resolved exporter configuration; the pure core of init(). **Attributes:** * `headers: dict[str, str]` * `logs_url: str` * `service_name: str` * `url: str` ### TelemetryHandle What init() returns: a shutdown handle, not a full provider. Both the enabled path and the disabled/no-op path satisfy this, so the return type is honest either way. Emit telemetry through the global OpenTelemetry APIs, not off this handle — init() registers both signal providers globally. #### force\_flush ```python force_flush(timeout_millis: int = 30000) -> bool ``` #### shutdown ```python shutdown() -> Any ``` ## Functions ### init ```python init(*, api_key: str | None = None, endpoint: str | None = None, logs_endpoint: str | None = None, headers: Mapping[str, str] | None = None, service_name: str | None = None, span_filter: SpanFilter | None = None, log_filter: LogFilter | None = None, enabled: bool = True) -> TelemetryHandle ``` Wire OTLP http/protobuf exporters + batch processors + one service.name resource, register global tracer and logger providers, and return a shutdown handle (call handle.shutdown() / handle.force\_flush()). Emit through the global OTel APIs. init() owns the global provider, so it exports every span by default (unlike ratel\_span\_processor, whose default gen\_ai.*/ratel.* filter exists for sharing a provider); pass span\_filter to narrow it. Repeated calls return the original Ratel-owned handle. It raises — pointing at ratel\_span\_processor — if a foreign provider is already registered globally. On first setup, pass enabled=False for an OTel-free no-op handle that needs no endpoint; once Ratel owns the provider, repeated calls return it regardless of options. Enabled initialization needs the \[otlp] extra and raises a clear error without it. Shutdown is terminal: after handle.shutdown(), a later init() raises rather than hand back the dead provider (OTel's global provider is set once per process). Note that the handle is shared across repeated calls, so shutting it down stops export for every caller. ### resolve\_otlp\_config ```python resolve_otlp_config(*, api_key: str | None = None, endpoint: str | None = None, logs_endpoint: str | None = None, headers: Mapping[str, str] | None = None, service_name: str | None = None, env: Mapping[str, str] | None = None) -> OtlpConfig ``` Resolve init() options into concrete exporter config. Accepts either api\_key= (falling back to RATEL\_API\_KEY; endpoint defaults to RATEL\_OTLP\_ENDPOINT, then the superseded RATEL\_URL; Authorization: Bearer) or endpoint=/headers= (custom endpoint / collector). The forms compose: explicit endpoint/api\_key values win over their environment fallbacks. An explicit api\_key sets the Bearer header; the RATEL\_API\_KEY fallback applies only when neither api\_key nor an explicit Authorization header is given, so ambient env never clobbers auth the caller set on purpose. env is injectable so the precedence is testable without a network. ### content\_capture\_mode ```python content_capture_mode(env: Mapping[str, str] | None = None) -> ContentCapture ``` Parse the ecosystem content-capture gate. A mode set via set\_content\_capture wins outright (env is the fallback, as in OTel); otherwise defaults to NO\_CONTENT when unset/empty/unrecognized. The legacy boolean form maps true to full capture (SPAN\_AND\_EVENT) and false to none. ### set\_content\_capture ```python set_content_capture(mode: ContentCapture | str | None) -> int ``` Programmatically set the content-capture mode. While set, content\_capture\_mode() returns this mode regardless of OTEL\_INSTRUMENTATION\_GENAI\_CAPTURE\_MESSAGE\_CONTENT — programmatic config wins over the environment, matching how OpenTelemetry treats env vars as the fallback for code-level configuration. Pass None to clear the override unconditionally and return to env-driven parsing. The mode is validated like the env var (case-insensitive, legacy true/false/1/0 accepted) and raises a ValueError on anything unrecognized — failing loud at config time instead of storing a value that would both disable capture and mask the env var. Returns a generation token identifying this call as the current owner of the override; pass it to clear\_content\_capture to clear only if no newer set has happened since (the safe form for shutdown/teardown hooks). ### clear\_content\_capture ```python clear_content_capture(generation: int) -> None ``` Clear the programmatic content-capture override, but only when generation — the token returned by set\_content\_capture — still identifies the most recent set. A stale token no-ops, so an old handle shutting down late cannot clobber an override a newer caller installed (and silently re-enable, or disable, capture via the env fallback). For an unconditional clear, use set\_content\_capture(None). ### ratel\_event\_filter ```python ratel_event_filter(record: ReadWriteLogRecord) -> bool ``` Default log filter: forward only named gen\_ai.\* / ratel.\* EventRecords. ### ratel\_log\_exporter ```python ratel_log_exporter(*, api_key: str | None = None, endpoint: str | None = None, logs_endpoint: str | None = None, headers: Mapping[str, str] | None = None) -> LogRecordExporter ``` Build the OTLP http/protobuf log exporter at the resolved logs endpoint. ### ratel\_log\_record\_processor ```python ratel_log_record_processor(*, api_key: str | None = None, endpoint: str | None = None, logs_endpoint: str | None = None, headers: Mapping[str, str] | None = None, log_filter: LogFilter | None = None, enabled: bool = True) -> LogRecordProcessor ``` A filtered BatchLogRecordProcessor for a host-owned LoggerProvider. ### ratel\_signal\_filter ```python ratel_signal_filter(span: ReadableSpan) -> bool ``` Default span filter: forward only signal-bearing spans — a ratel.\* span name, or any attribute key under gen\_ai.\* / ratel.\*. This is what lets Ratel share a provider with e.g. Langfuse + the Vercel AI SDK and ingest only the gen\_ai/ratel signal (the AI SDK's gen\_ai.\* spans + Ratel's own ratel.search / execute\_tool), dropping the framework's ai.\* wrapper noise. ### ratel\_span\_exporter ```python ratel_span_exporter(*, api_key: str | None = None, endpoint: str | None = None, headers: Mapping[str, str] | None = None) -> SpanExporter ``` Build the OTLP http/protobuf span exporter at the resolved Ratel endpoint. The standalone exporter for callers wiring their own span-processor; ratel\_span\_processor batches over it. Carries no resource — the caller's provider owns service.name. Needs the \[otlp] extra; raises a clear error otherwise. ### ratel\_span\_processor ```python ratel_span_processor(*, api_key: str | None = None, endpoint: str | None = None, headers: Mapping[str, str] | None = None, span_filter: SpanFilter | None = None, enabled: bool = True) -> SpanProcessor ``` A BatchSpanProcessor over the Ratel OTLP exporter that forwards only the spans passing span\_filter (default ratel\_signal\_filter; pass `lambda _s: True` to forward everything). Add it to your own provider (`provider.add_span_processor(ratel_span_processor(...))`) to send Ratel telemetry alongside another provider — no global side effects, no resource. Greenfield apps that want Ratel to own the provider should call init() instead. Pass enabled=False for an OTel-free no-op processor that needs no endpoint. Enabled processors need the \[otlp] extra and raise a clear error without it. ## Constants * `CAPTURE_CONTENT_ENV: Final = 'OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT'` — The ecosystem instrumentation env var gating message/tool content capture. Default off; honored by init() rather than a Ratel-invented flag (CONVENTIONS.md § Capture gating). Values: legacy boolean, or the enum NO\_CONTENT (default) / SPAN\_ONLY / EVENT\_ONLY / SPAN\_AND\_EVENT. * `EXECUTE_TOOL: Final = 'execute_tool'` — execute\_tool — the gen\_ai.operation.name value for a tool invocation. Deliberately the standard OTel gen\_ai operation, not a bespoke ratel.invoke span, so a generic OTel backend already understands it (locked 2026-07-05). The invoke is enriched with ratel.\* attributes. * `GEN_AI_INFERENCE_DETAILS: Final = 'gen_ai.client.inference.operation.details'` — gen\_ai.client.inference.operation.details — inference request/response content. * `GEN_AI_INPUT_MESSAGES: Final = 'gen_ai.input.messages'` — gen\_ai.input.messages — the input message list (Opt-In content). * `GEN_AI_OPERATION_NAME: Final = 'gen_ai.operation.name'` — gen\_ai.operation.name — set to EXECUTE\_TOOL for a tool invocation. * `GEN_AI_OUTPUT_MESSAGES: Final = 'gen_ai.output.messages'` — gen\_ai.output.messages — generated outputs; every message includes finish\_reason. * `GEN_AI_SYSTEM_INSTRUCTIONS: Final = 'gen_ai.system_instructions'` — gen\_ai.system\_instructions — the system prompt as a bare parts\[] (Opt-In content). * `GEN_AI_TOOL_CALL_ARGUMENTS: Final = 'gen_ai.tool.call.arguments'` — gen\_ai.tool.call.arguments — tool arguments (Opt-In content, gated). * `GEN_AI_TOOL_CALL_ID: Final = 'gen_ai.tool.call.id'` — gen\_ai.tool.call.id — tool call id, when available. * `GEN_AI_TOOL_CALL_RESULT: Final = 'gen_ai.tool.call.result'` — gen\_ai.tool.call.result — tool result (Opt-In content, gated). * `GEN_AI_TOOL_NAME: Final = 'gen_ai.tool.name'` — gen\_ai.tool.name — the capability tool id. * `RATEL_AUTH_FLOW: Final = 'ratel.auth.flow'` — ratel.auth.flow — MCP auth flow. * `RATEL_AUTH_OUTCOME: Final = 'ratel.auth.outcome'` — ratel.auth.outcome — "ok" / "refreshed" / "needs\_auth" / "failed" (see AuthOutcome). * `RATEL_ORIGIN: Final = 'ratel.origin'` — ratel.origin — direct library call vs agent-synthesized (shared attribute). * `RATEL_SEARCH: Final = 'ratel.search'` — ratel.search — capability search (unifies tool-search and skill-search). * `RATEL_SEARCH_HIT_COUNT: Final = 'ratel.search.hit_count'` — ratel.search.hit\_count — results returned. * `RATEL_SEARCH_QUERY: Final = 'ratel.search.query'` — ratel.search.query — the search text (content, gated like message content). * `RATEL_SEARCH_RESULTS: Final = 'ratel.search.results'` — ratel.search.results — Opt-In search-content event; gated like content. * `RATEL_SEARCH_TARGET: Final = 'ratel.search.target'` — ratel.search.target — "tool" or "skill" (see SearchTarget). * `RATEL_SEARCH_TOP_K: Final = 'ratel.search.top_k'` — ratel.search.top\_k — requested result count. * `RATEL_SKILL_ID: Final = 'ratel.skill.id'` — ratel.skill.id — skill loaded on the ratel.skill.load span. * `RATEL_SKILL_LOAD: Final = 'ratel.skill.load'` — ratel.skill.load — skill content load (get\_skill\_content). * `RATEL_TOOL_ARGS_SIZE_BYTES: Final = 'ratel.tool.args_size_bytes'` — ratel.tool.args\_size\_bytes — argument payload size on the execute\_tool span. * `RATEL_TOOL_EXECUTION_DETAILS: Final = 'ratel.tool.execution.details'` — ratel.tool.execution.details — Opt-In structured tool arguments/result event. * `RATEL_UPSTREAM_REGISTER: Final = 'ratel.upstream.register'` — ratel.upstream.register — upstream-MCP ingest. * `RATEL_UPSTREAM_SERVER: Final = 'ratel.upstream.server'` — ratel.upstream.server — upstream MCP server backing a tool / auth flow. * `RATEL_UPSTREAM_TOOL_COUNT: Final = 'ratel.upstream.tool_count'` — ratel.upstream.tool\_count — tools ingested on register. * `RATEL_UPSTREAM_TRANSPORT: Final = 'ratel.upstream.transport'` — ratel.upstream.transport — "stdio" / "http" / "sse" / ... * `SEMCONV_VERSION: Final = '1.42.0'` — The pinned OpenTelemetry semantic-conventions version this vocabulary tracks (the gen\_ai group). The pin is the contract; consumers read against this exact version, never "latest" (CONVENTIONS.md § The pin). * `API_KEY_ENV = 'RATEL_API_KEY'` — Env var whose value is the default API key when api\_key= is omitted. * `ENDPOINT_ENV = OTLP_ENDPOINT_ENV` — Backwards-compatible alias of :data:`OTLP_ENDPOINT_ENV`, the var callers set. * `OTLP_ENDPOINT_ENV = 'RATEL_OTLP_ENDPOINT'` — Env var whose value is the default OTLP traces endpoint. * `LEGACY_ENDPOINT_ENV = 'RATEL_URL'` — Superseded spelling, still read as a fallback. `RATEL_URL` also names the SDK's catalog source (ADR-0003), so overloading it as the OTLP endpoint conflated two unrelated destinations. Setting it still works and warns. * `DEFAULT_SERVICE_NAME = 'ratel'` — service.name used when the caller does not pass one. * `SpanFilter = Callable[['ReadableSpan'], bool]` — Predicate deciding whether a finished span is forwarded to Ratel. * `LogFilter = Callable[['ReadWriteLogRecord'], bool]` --- # Changelog Source: https://www.ratel.sh/docs/packages/python/ratel-ai-telemetry/changelog > ratel-ai-telemetry release history · [PyPI](https://pypi.org/project/ratel-ai-telemetry/) · [source](https://github.com/ratel-ai/ratel/tree/main/src/telemetry/python) > > Synced verbatim from the package's `CHANGELOG.md` at `ratel-ai/ratel@d8635d5` — do not edit by hand. Regenerate with `pnpm sync:api` in `apps/docs`. All notable changes to `ratel-ai-telemetry` (the Python telemetry helper) are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this package adheres to [Semantic Versioning](https://semver.org/). ## \[0.1.3] - 2026-07-26 ### Added * `GEN_AI_SYSTEM_INSTRUCTIONS`, `GEN_AI_INPUT_MESSAGES`, `GEN_AI_OUTPUT_MESSAGES`, `RATEL_TOOL_EXECUTION_DETAILS`, distinct Logs endpoint resolution, and composable `ratel_log_record_processor`, `ratel_log_exporter`, and `ratel_event_filter` helpers. * `OTLP_ENDPOINT_ENV` (`RATEL_OTLP_ENDPOINT`), the dedicated OTLP traces endpoint variable, and `LEGACY_ENDPOINT_ENV` (`RATEL_URL`) naming the superseded one. `RATEL_URL` also selects the SDK's catalog source (ADR-0003), so overloading it as the OTLP destination conflated two unrelated destinations; TypeScript made the same move in `@ratel-ai/telemetry` 0.2.0. ### Changed * `init()` now owns and shuts down matched tracer and logger providers. * Define content events as structured OpenTelemetry Logs `EventRecord`s and reserve inference output messages for model outputs with `finish_reason`. * `resolve_otlp_config()` resolves the endpoint as explicit `endpoint=` > `RATEL_OTLP_ENDPOINT` > `RATEL_URL`, and the missing-endpoint error names the new variable. Nothing breaks here: a `RATEL_URL`-only install keeps working and raises a `DeprecationWarning` naming the replacement. `ENDPOINT_ENV` stays exported and now equals `OTLP_ENDPOINT_ENV`, so code that sets `os.environ[ENDPOINT_ENV]` targets the new variable automatically. The `RATEL_URL` fallback is scheduled for removal in the next minor. * `RATEL_ORIGIN` is now specified for third-party `gen_ai.*` spans a framework adapter overlays, not just Ratel's own search/invoke spans. The constant, its `direct | agent` values, and `SEMCONV_VERSION` are unchanged; only the shared vocabulary spec widened (`CONVENTIONS.md`). ## \[0.1.2] - 2026-07-11 ### Added * `API_KEY_ENV` (`RATEL_API_KEY`) and API-key environment fallback. Explicit `api_key=` remains authoritative; the env fallback applies only when neither `api_key=` nor an explicit `Authorization` header is given, so ambient `RATEL_API_KEY` never clobbers a caller-supplied auth header. * On first setup, `init(enabled=False)` returns an OTel-free no-op handle without endpoint configuration or the `[otlp]` extra; once Ratel owns the provider, repeated calls return it. `ratel_span_processor(enabled=False)` always returns a no-op processor. * `init(span_filter=...)` filters the turnkey provider, and repeated/module-reloaded calls return the exact Ratel-owned handle while foreign providers still raise. If another `init()` wins the registration race, the loser now returns that Ratel-owned handle instead of raising. * Public `TelemetryHandle` protocol describing `init()`'s return (`shutdown()` / `force_flush()`). ### Changed * `init()` is now typed to return a `TelemetryHandle` (shutdown handle), not a `TracerProvider` — the disabled/no-op path no longer masquerades as a full provider. Emit spans through the global OTel API (`opentelemetry.trace.get_tracer(...)`). * `init()` shutdown is terminal: OTel's global provider is set once per process, so after the handle's `shutdown()` a later `init()` raises instead of returning a provider whose exporter is dead. A shared handle's `shutdown()` stops export for all callers. ## \[0.1.1] - 2026-07-10 ### Added * `set_content_capture(mode)`: programmatic override of the content-capture gate. While set, `content_capture_mode()` returns the given mode regardless of `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` — code-level config wins over the environment, matching how OpenTelemetry treats env vars as the fallback for programmatic configuration. The mode is validated exactly like the env var (case-insensitive, trimmed, legacy `true`/`false`/`1`/`0` forms accepted) and raises a `ValueError` naming the valid values on anything unrecognized — failing loud at config time instead of storing a value that would both disable capture and mask the env var. Pass `None` to clear unconditionally. Returns a generation token identifying the call as the current owner of the override. * `clear_content_capture(generation)`: clears the override only when `generation` (the token returned by `set_content_capture`) still identifies the most recent set. A stale token no-ops, so an old telemetry handle shutting down late cannot clobber an override a newer caller installed and silently flip capture back to the env value. ## \[0.1.0] - 2026-07-06 ### Added * The telemetry helper (ADR-0015): the full `ratel.*` vocabulary as constants (attribute keys, span/event names, `gen_ai.*` interop keys, and the `Origin`/`SearchTarget`/`AuthOutcome` value enums) pinned to OpenTelemetry semconv `gen_ai` v1.42.0. * `init()` sugar over the OpenTelemetry Python SDK: wires an OTLP `http/protobuf` exporter to `RATEL_URL` (or `endpoint=`/`headers=`) with a `service.name` resource and batch processor, and returns the provider as a shutdown handle. `content_capture_mode()` reads the `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` gate (default off). OTel deps pinned below 1.42 to keep Python 3.9 support. * Shared contract-against-the-pin conformance suite (`../conformance/fixtures.json`): spans built from the constants through the real SDK must emit the exact pinned keys. * Usage example in the README (runnable end-to-end in `examples/telemetry-python`). * `ratel_span_processor()` / `ratel_span_exporter()` + the default `ratel_signal_filter`: a composable OTLP span-processor for multi-provider coexistence. OpenTelemetry's model is one provider with many span-processors, so a partner already running one (e.g. Langfuse + the Vercel AI SDK) calls `provider.add_span_processor(ratel_span_processor(...))` to dual-export to Ratel — forwarding only the `gen_ai.*` / `ratel.*` signal (overridable via `span_filter`), so the framework's `ai.*` wrapper noise stays out of Ratel. Resolvable top-level via the lazy accessor (a plain `import ratel_ai_telemetry` still pulls no OTel). * A regression guard that a plain `import ratel_ai_telemetry` pulls no OpenTelemetry SDK. ### Changed * The OpenTelemetry SDK is an optional `[otlp]` extra: importing `ratel_ai_telemetry` pulls no OTel SDK (ADR-0015), so the SDK (emit), the server (read), and edge/serverless emitters take the `ratel.*` vocabulary weight-free. `init()` lives in the `ratel_ai_telemetry.otlp` submodule (behind the extra) and raises a clear "install `ratel-ai-telemetry[otlp]`" error when it is absent; a lazy top-level accessor keeps `from ratel_ai_telemetry import init` working. * `init()` refactored onto `ratel_span_processor` (still exports every span — it owns the provider) and now raises, pointing at `ratel_span_processor`, when a `TracerProvider` is already registered globally, instead of silently no-op'ing. * Released as an independent PyPI unit under the `telemetry-py-v*` tag prefix. --- # Ratel Cloud Source: https://www.ratel.sh/docs/cloud Early access Ratel Cloud turns Ratel telemetry into a hosted view of context usage, retrieval quality, and agent performance. It also serves two APIs your agent can call with its project key. [Embeddings](https://www.ratel.sh/docs/cloud/embeddings) OpenAI-compatible embeddings. Point the SDK's endpoint source at it. [Tool Picker](https://www.ratel.sh/docs/cloud/tool-picker) Send a query, get back the tools to call from your own catalog, ranked. [Credits and billing](https://www.ratel.sh/docs/cloud/credits) One balance per API: monthly allowance first, then prepaid credit. [Send telemetry](https://www.ratel.sh/docs/cloud/opentelemetry) Point any OpenTelemetry exporter at Cloud. No Ratel SDK needed. ## API keys Every Cloud API authenticates with a project API key. * Create one in your project's **Settings > API keys** on [cloud.ratel.sh](https://cloud.ratel.sh). * Send it as `Authorization: Bearer `. * The examples here read it from `RATEL_API_KEY`. --- # Send telemetry Source: https://www.ratel.sh/docs/cloud/opentelemetry Ratel Cloud ingests standard OpenTelemetry traces. Any agent that emits LLM spans can send them, in any language. Using the Ratel SDK already? See [Observe your agent](https://www.ratel.sh/docs/observability) instead. ## What you need * A project API key. Create one in your project's **Settings > API keys** on [cloud.ratel.sh](https://cloud.ratel.sh). * An OTLP/HTTP trace exporter. Protobuf and JSON both work; gRPC does not. | Setting | Value | | -------- | ---------------------------------- | | Endpoint | `https://cloud.ratel.sh/v1/traces` | | Header | `Authorization: Bearer ` | | Protocol | `http/protobuf` or `http/json` | ## Configure with environment variables Most OpenTelemetry SDKs read these at startup: ```bash export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT="https://cloud.ratel.sh/v1/traces" export OTEL_EXPORTER_OTLP_TRACES_HEADERS="Authorization=Bearer%20$RATEL_API_KEY" export OTEL_EXPORTER_OTLP_TRACES_PROTOCOL="http/protobuf" # Send message content so Cloud can break down what fills the context: export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT="true" ``` The space after `Bearer` is URL-encoded as `%20`. ## Or configure in code **Python (OpenAI)** ```bash pip install opentelemetry-sdk opentelemetry-exporter-otlp-proto-http \ opentelemetry-instrumentation-openai-v2 ``` ```python import os from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter from opentelemetry.instrumentation.openai_v2 import OpenAIInstrumentor provider = TracerProvider() provider.add_span_processor( BatchSpanProcessor( OTLPSpanExporter( endpoint="https://cloud.ratel.sh/v1/traces", headers={"Authorization": f"Bearer {os.environ['RATEL_API_KEY']}"}, ) ) ) trace.set_tracer_provider(provider) OpenAIInstrumentor().instrument() ``` Every OpenAI call is now traced. Set `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true` to include message content. **TypeScript (Vercel AI SDK)** ```bash npm install @opentelemetry/sdk-node @opentelemetry/exporter-trace-otlp-proto ``` ```ts import { NodeSDK } from "@opentelemetry/sdk-node"; import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto"; import { generateText } from "ai"; const sdk = new NodeSDK({ traceExporter: new OTLPTraceExporter({ url: "https://cloud.ratel.sh/v1/traces", headers: { Authorization: `Bearer ${process.env.RATEL_API_KEY}` }, }), }); sdk.start(); const { text } = await generateText({ model, prompt: "Summarize the open tasks", experimental_telemetry: { isEnabled: true }, }); ``` The AI SDK records prompts and tool calls by default. Cloud reads its `ai.*` attributes directly. Any other emitter that follows the [OpenTelemetry GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/) works the same way. ## What Cloud reads | You get | From | Needs | | ------------------------------------------------------------- | ---------------------------------------------------------------- | ----------------------------------------------- | | Tokens, cost, model, latency | `gen_ai.usage.*`, `gen_ai.request.model`, `gen_ai.provider.name` | Nothing extra | | Context breakdown: user input, history, tools, skills, memory | Message content | Content capture on | | Tool calls | `execute_tool` spans and tool-call messages | Nothing extra | | Unused tools | `gen_ai.tool.definitions` | Your instrumentation emitting the offered tools | Without content capture, totals stay exact and the breakdown counts everything as user input. Content capture sends prompts and responses to Cloud. Turn it on only if that is acceptable for your data. ## Check it works Send one test span: ```bash curl -s https://cloud.ratel.sh/v1/traces \ -H "Authorization: Bearer $RATEL_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "resourceSpans": [{ "scopeSpans": [{ "spans": [{ "traceId": "0123456789abcdef0123456789abcdef", "spanId": "0123456789abcdef", "name": "chat gpt-4o", "kind": 3, "startTimeUnixNano": "1751800000000000000", "endTimeUnixNano": "1751800001200000000", "attributes": [ { "key": "gen_ai.operation.name", "value": { "stringValue": "chat" } }, { "key": "gen_ai.provider.name", "value": { "stringValue": "openai" } }, { "key": "gen_ai.request.model", "value": { "stringValue": "gpt-4o" } }, { "key": "gen_ai.usage.input_tokens", "value": { "intValue": "1200" } }, { "key": "gen_ai.usage.output_tokens", "value": { "intValue": "80" } } ] }] }] }] }' ``` A `202` with `{"accepted":1,"calls":1,"spans":1}` means Cloud stored it. The call then shows on your project's dashboard. ## Limits and errors * Up to 4 MB per export. * LLM calls per month: Free 50,000, Plus 500,000, Pro 5,000,000, Enterprise unlimited. | Status | Meaning | | ------ | ----------------------------------------------- | | `202` | Accepted. | | `401` | Missing, invalid, or revoked API key. | | `413` | Export over 4 MB. Lower your batch size. | | `429` | Monthly call limit reached. Upgrade in Billing. | --- # Embeddings Source: https://www.ratel.sh/docs/cloud/embeddings `POST https://cloud.ratel.sh/v1/embeddings` turns text into vectors. It speaks the OpenAI embeddings contract, so anything that calls OpenAI's `/v1/embeddings` can call it. No separate provider account is needed. ## Use it from the SDK Point the [OpenAI-compatible endpoint source](https://www.ratel.sh/docs/embedding-models#the-four-sources) at Ratel Cloud. **TypeScript** ```ts import { ratel } from "@ratel-ai/sdk"; const r = ratel({ method: "hybrid", embedding: { url: "https://cloud.ratel.sh/v1/embeddings", model: "ratel-embed-v1", apiKeyEnv: "RATEL_API_KEY", }, }); ``` **Python** ```python from ratel_ai import ToolCatalog catalog = ToolCatalog( method="hybrid", embedding={ "url": "https://cloud.ratel.sh/v1/embeddings", "model": "ratel-embed-v1", "api_key_env": "RATEL_API_KEY", }, ) ``` ## Call it directly ```bash curl -s https://cloud.ratel.sh/v1/embeddings \ -H "Authorization: Bearer $RATEL_API_KEY" \ -H "Content-Type: application/json" \ -d '{"model": "ratel-embed-v1", "input": ["create a task", "search documents"]}' ``` ```json { "object": "list", "data": [ { "object": "embedding", "index": 0, "embedding": [0.0123, -0.0456, "..."] }, { "object": "embedding", "index": 1, "embedding": [0.0789, 0.0012, "..."] } ], "model": "ratel-embed-v1", "usage": { "prompt_tokens": 6, "total_tokens": 6 } } ``` ## Request * **`input`**: a string or an array of up to 512 strings. * **`model`**: `ratel-embed-v1`, or omit it. Any other value is a `400`. * **`encoding_format`**: only `"float"`. * **Body size**: up to 4 MB. Vectors have 768 dimensions. English text works best. ## Long inputs An input longer than the model's 512-token window is cut to fit instead of failing the batch. The response then carries `truncated_inputs`: the indexes of the inputs that were cut. It is absent when nothing was cut. ## Pricing **€0.0075 per 1M input tokens**, spent from your [Embeddings balance](https://www.ratel.sh/docs/cloud/credits). | Plan | Included each month | | ---------- | --------------------------- | | Free | None. Buy credit to use it. | | Plus | €5 | | Pro | €20 | | Enterprise | €20, or custom | ## Errors Errors use the OpenAI envelope: `{ "error": { "message", "type" } }`. | Status | Meaning | | ------------- | --------------------------------------------------------------------------------------------------- | | `400` | Bad body, unsupported `model`, or the input was rejected. | | `401` | Missing, invalid, or revoked API key. | | `402` | Out of Embeddings credit (`insufficient_quota`). [Top up](https://www.ratel.sh/docs/cloud/credits). | | `413` | Body over 4 MB. | | `429` | Rate limited upstream. Retry with backoff. | | `502` / `503` | Temporarily unavailable. Retry. | Usage per project (requests, tokens, latency) is on **Models > Embeddings** in Cloud. --- # Tool Picker Source: https://www.ratel.sh/docs/cloud/tool-picker `POST https://cloud.ratel.sh/v1/tools/pick` answers which of your project's tools an agent should call for a query. You send only the query. Tool Picker reads the catalog your SDK already syncs to Cloud, so there is nothing to upload. ## Pick a tool **curl** ```bash curl -s https://cloud.ratel.sh/v1/tools/pick \ -H "Authorization: Bearer $RATEL_API_KEY" \ -H "Content-Type: application/json" \ -d '{"query": "leave a progress note on the task", "mode": "precise", "top_k": 3}' ``` **TypeScript** ```ts const res = await fetch("https://cloud.ratel.sh/v1/tools/pick", { method: "POST", headers: { Authorization: `Bearer ${process.env.RATEL_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ query: "leave a progress note on the task", top_k: 3 }), }); const { tools, confident } = await res.json(); ``` **Python** ```python import os, requests res = requests.post( "https://cloud.ratel.sh/v1/tools/pick", headers={"Authorization": f"Bearer {os.environ['RATEL_API_KEY']}"}, json={"query": "leave a progress note on the task", "top_k": 3}, ) best = res.json()["tools"][0] ``` ```json { "mode": "precise", "tools": [ { "id": "add_task_comment", "name": "add_task_comment", "description": "Comment on a task.", "score": 0.94 }, { "id": "update_task", "name": "update_task", "description": "Update a task.", "score": 0.04 } ], "confident": true, "usage": { "candidates": 99, "questions": 1, "input_tokens": 9580 } } ``` ## Modes | Mode | How it decides | Speed | Price | | ------------------- | ------------------------------------------------------------------- | ------------ | ------------------ | | `instant` | Keyword relevance against each tool's searchable description | A few ms | Free | | `precise` (default) | A decision model reads your catalog and scores every tool | About 300 ms | Tool Picker credit | | `exhaustive` | The decision model reads every tool, in rounds, at any catalog size | 1 to a few s | Tool Picker credit | * **Precise** reads the whole catalog up to about 150 tools. Above that it reads the 25 most relevant. * **Exhaustive** reads every tool, up to 2,000. On a catalog that fits one read, it is the same as Precise. * **Instant** has no model in the loop. Use it for high-volume routing or a first pass. On a real 99-tool production catalog, Precise matched the reference answer for 93.5% of queries, and had it in the top 5 for 98.4%. ## Request * **`query`** (required): what the agent wants to do, up to 2,000 characters. * **`mode`**: `instant`, `precise` (default), or `exhaustive`. * **`top_k`**: how many tools to return, 1 to 20 (default 5). ## Response * **`tools`**: best first. Each has the agent-facing `description` and a `score`. * **`score`**: for Precise and Exhaustive, the model's probability that this is the tool to call next. For Instant, keyword relevance where the best match is 1. * **`confident`**: `true` when the top pick clears the confidence bar calibrated against expert judges. Below it, treat `tools` as a shortlist. `null` for Instant. * **`usage`**: tools considered, model questions asked, and input tokens read. An empty `tools` list means nothing in the catalog matched. ## Pricing Precise and Exhaustive are billed by the tokens the model reads: **€0.0441 per 1M tokens**. A pick on a 100-tool catalog reads about 9,600 tokens; smaller catalogs cost less. Instant is free. They spend your [Tool Picker balance](https://www.ratel.sh/docs/cloud/credits), separate from Embeddings. | Plan | Included each month | About | | ---------- | ------------------- | ------------- | | Free | €1 | 2,300 picks | | Plus | €5 | 12,000 picks | | Pro | €40 | 95,000 picks | | Enterprise | €100, or custom | 235,000 picks | ## Rate limits Limits are per project, per minute. | Plan | Instant | Precise and Exhaustive | | --------------------- | ------- | ---------------------- | | Free | 60 | 10 | | Plus, Pro, Enterprise | 1,200 | 300 | A `429` carries `Retry-After` in seconds. ## Errors Errors use the same envelope as [Embeddings](https://www.ratel.sh/docs/cloud/embeddings#errors). | Status | Meaning | | ------------- | ----------------------------------------------------------------------------------------------- | | `400` | Bad body: missing `query`, unknown `mode`, or `top_k` out of range. | | `401` | Missing, invalid, or revoked API key. | | `402` | Out of Tool Picker credit. [Top up](https://www.ratel.sh/docs/cloud/credits), or use `instant`. | | `409` | The project has no synced tools. Sync your catalog with the Ratel SDK first. | | `429` | Rate limit reached. Wait `Retry-After` seconds. | | `502` / `503` | Temporarily unavailable. Retry, or fall back to `instant`. | | `504` | The pick took over 45 s. Retry, or use `precise` on a large catalog. | ## Earlier endpoint `POST /v1/pick` still works with its original request and response shape. It answers with a `Deprecation` header. Move to `/v1/tools/pick`. --- # Credits and billing Source: https://www.ratel.sh/docs/cloud/credits Each hosted API has its own balance: one for [Embeddings](https://www.ratel.sh/docs/cloud/embeddings), one for [Tool Picker](https://www.ratel.sh/docs/cloud/tool-picker). Spending one never drains the other. Manage both under **Organization > Billing > API credits** on [cloud.ratel.sh](https://cloud.ratel.sh). Balances belong to the organization and are shared by all its projects. ## How a call is paid 1. Your plan's **monthly allowance** is spent first. 2. Then **prepaid credit**, which you buy and which never expires. 3. When both are empty, the API answers `402` until you top up. Tool Picker's Instant mode is free and never touches a balance. ## Monthly allowance | Plan | Embeddings | Tool Picker | | ---------- | -------------- | --------------- | | Free | None | €1 | | Plus | €5 | €5 | | Pro | €20 | €40 | | Enterprise | €20, or custom | €100, or custom | * It resets at the start of each billing period. On Free, it renews every 30 days from first use. * Unused allowance does not carry over. * Upgrading mid-period grants the higher allowance at once. Cancelling returns it to Free's. ## Prepaid credit * Buy packs of €10, €25, €50 or €100, or any multiple of €100. * Each top-up is for one API. Pick which card you buy from. * Credit never expires and survives plan changes. ## Auto-recharge Turn it on per API so a balance never runs dry. * Set a **threshold** and an **amount**: when credit falls below the threshold, Cloud charges your saved card for the amount. * Save a card once. Both balances use it. * Each balance recharges at most once per hour. ## Usage * Per-project usage is on **Models > Embeddings** and **Models > Tool Picker**. * Each balance's top-ups and recharges are listed on its Billing card. --- # Introduction Source: https://www.ratel.sh/docs/local Ratel Local is one MCP server for your whole tool estate. It connects to your upstream MCP servers and exposes a small, searchable capability surface to Claude Code, Codex, Cursor, or any other MCP client. Your client sees `search_capabilities` and `invoke_tool`, plus `get_skill_content` when skills are configured and `auth` for [authorizing OAuth upstreams](https://www.ratel.sh/docs/local/oauth). Upstream tool schemas stay out of context until a search makes them relevant. Ratel Local is the gateway host in [Ratel's architecture](https://www.ratel.sh/docs/architecture). The capability contracts belong to [progressive disclosure](https://www.ratel.sh/docs/optimization#progressive-disclosure); Ratel Local owns host setup, upstream configuration, authentication, and operation. > **Start with setup** > > `ratel-local setup` prepares the background daemon, detects Claude Code and Codex, > connects the agents you select, and separately offers a reviewed import of existing > MCP servers and skills. [Quickstart](https://www.ratel.sh/docs/local/quickstart) Install Ratel Local, run setup, and verify capability search. [Cursor](https://www.ratel.sh/docs/local/cursor) Add Ratel Local to Cursor manually through its standard `mcp.json` configuration. [MCP servers](https://www.ratel.sh/docs/local/mcp-servers) Configure scopes, add upstreams, and verify the running gateway. [Daemon](https://www.ratel.sh/docs/local/daemon) Understand the background service, project gateways, lifecycle, and upgrades. [Retrieval](https://www.ratel.sh/docs/local/retrieval) Keep BM25 or opt into semantic and hybrid search for one scope. [Import or link](https://www.ratel.sh/docs/local/import-or-link) Move native MCP entries into Ratel Local or connect a host without migrating them. [Browser UI](https://www.ratel.sh/docs/local/browser-ui) Manage local configuration in a loopback-only browser app. [Managing skills](https://www.ratel.sh/docs/local/managing-skills) Register skills by scope and wire the prompt-time preload hook. [OAuth upstreams](https://www.ratel.sh/docs/local/oauth) Authorize remote servers, inspect status, and recover from a 401. [Statusline & local telemetry](https://www.ratel.sh/docs/local/statusline-telemetry) Inspect local events and show estimated context savings in Claude Code. [Experimental Cloud telemetry](https://www.ratel.sh/docs/local/cloud-telemetry) Review the default-off feature gate before enabling native trace export. [Embed as a library](https://www.ratel.sh/docs/local/library) Build and serve a Ratel Local gateway from your own Node process. [Troubleshooting](https://www.ratel.sh/docs/local/troubleshooting) Recover from host, upstream, authentication, scope, and telemetry failures. ## Ratel Local or an SDK? Use Ratel Local when your tools already live behind MCP servers and you do not own the agent loop. Use a [Ratel SDK](https://www.ratel.sh/docs/sdks) when you want in-process catalog control, local function tools, or framework wiring. Both expose the same capability-tool contract. Ratel Local ships as [`@ratel-ai/ratel-local`](https://www.npmjs.com/package/@ratel-ai/ratel-local). The CLI binary is `ratel-local` and requires Node.js 20.6 or newer. --- # Quickstart: Claude Code & Codex Source: https://www.ratel.sh/docs/local/quickstart Ratel Local gives Claude Code or Codex one searchable catalog instead of every tool schema from every upstream server. The stable release uses one persistent local daemon and a small project-scoped connector in each agent session. If you use Cursor, follow the [manual Cursor setup](https://www.ratel.sh/docs/local/cursor). The setup wizard has native adapters for Claude Code and Codex only. ## Prerequisites * macOS or Linux for the managed background service. * Node.js 20.6 or newer. * Claude Code, Codex, or both installed before agent detection. ### Install Ratel Local Install the CLI globally so the background service has a durable executable: ```bash npm install --global @ratel-ai/ratel-local ratel-local --version ``` The version command should print a version number. The package and CLI are `@ratel-ai/ratel-local` and `ratel-local`. ### Run setup ```bash ratel-local setup ``` The wizard: * installs, replaces, or starts the per-user daemon; * detects Claude Code and Codex, then asks which agents to connect; * installs the Ratel Local plugin for each selected agent, with an explicit MCP fallback only if plugin installation fails; * offers existing MCP servers and skills as a separate previewed import; * confirms before committing an import and backs up changed configuration. Stable packages use the **Ratel** marketplace from the repository's default `main` branch. Leave the marketplace reference at its default for stable releases. Setup is idempotent. A matching running daemon is a no-op, and existing agent links are reconciled safely. > **No global installation yet?** > > Run `npx -y @ratel-ai/ratel-local setup`. Setup uses the resolved stable CLI for > the login service instead of fetching an unpublished version later. ### Check the daemon and agent link ```bash ratel-local daemon status ``` The daemon should report `running` and its loopback endpoint. The earlier version check confirms the CLI is available. Then inspect the selected host: **Claude Code** ```bash claude mcp get ratel-local ``` Run `/reload-plugins` or restart Claude Code. **Codex** ```bash codex mcp get ratel-local --json ``` Confirm that the plugin MCP server is enabled, then start a new Codex session. The plugin runs `npx -y @ratel-ai/ratel-local connect`. The connector sends the resolved project root to the authenticated loopback daemon; it does not start a second full gateway process. ### Add an upstream Add servers directly to Ratel when you did not import existing ones: ```bash ratel-local mcp add --scope user context7 -- npx -y @upstash/context7-mcp ratel-local mcp list ``` Use `--scope project` for team configuration or `--scope local` for machine-specific project configuration. Reconnect the agent after changing the effective catalog. If the first package download or upstream probe stalls, see [First-run networking](https://www.ratel.sh/docs/local/troubleshooting#first-run-networking). ### Verify capability search Ask the agent to call Ratel explicitly: ```text Call Ratel's search_capabilities tool with: {"query":"look up current React framework documentation","topKTools":3,"topKSkills":1} Return the raw result. ``` The result should contain a `tools` bucket with matching upstream tools and a `skills` bucket. The full request and response contract is on [Progressive disclosure tools](https://www.ratel.sh/docs/progressive-disclosure-tools). ## Safe automation Plain `--yes` changes only the daemon. It never selects agents, imports native configuration, or enables experimental telemetry: ```bash ratel-local setup --yes ratel-local setup --daemon-only --yes ``` Agent automation requires an explicit selection. Repeat `--agent`, or use `auto` for all detected supported agents: ```bash ratel-local setup --yes --agent claude-code --agent codex ratel-local setup --yes --agent auto ``` Automated setup never imports MCP servers or skills. Use the expert command only when migration is intended: ```bash ratel-local import --yes --agent claude-code ratel-local import --yes --agent codex ``` `--port N` chooses the daemon port on first installation. `--daemon-only` cannot be combined with `--agent`. ## Next steps [Daemon lifecycle](https://www.ratel.sh/docs/local/daemon) Manage the background service, project connections, upgrades, and removal. [Import or link](https://www.ratel.sh/docs/local/import-or-link) Review migration conflicts, plugin fallback behavior, and backups. [Retrieval](https://www.ratel.sh/docs/local/retrieval) Keep model-free BM25 or opt one scope into semantic or hybrid search. [Troubleshooting](https://www.ratel.sh/docs/local/troubleshooting) Diagnose daemon, host, project, upstream, and version problems. --- # Cursor Source: https://www.ratel.sh/docs/local/cursor Cursor can run Ratel Local as a standard stdio MCP server. The setup is manual because Ratel Local has no Cursor import, link, or setup adapter. > **Do not use a Cursor agent flag** > > `ratel-local import --agent cursor` and `ratel-local link --agent cursor` > are not supported. Those commands only adapt Claude Code and Codex configuration. ## Add Ratel Local manually ### Add upstreams to Ratel Run from your project when you use project or local scope. ```bash npx -y @ratel-ai/ratel-local mcp add \ --scope user context7 -- npx -y @upstash/context7-mcp npx -y @ratel-ai/ratel-local mcp list ``` Use `--scope project` for shared project configuration or `--scope local` for private project overrides. See [MCP servers](https://www.ratel.sh/docs/local/mcp-servers#configuration-scopes). ### Add the Cursor MCP entry Create `.cursor/mcp.json` in the project, or `~/.cursor/mcp.json` to make the gateway available in every workspace. Follow Cursor's [MCP configuration reference](https://cursor.com/docs/mcp). ```json { "mcpServers": { "ratel-local": { "type": "stdio", "command": "npx", "args": [ "-y", "@ratel-ai/ratel-local", "serve", "--auto-config", "--project-root", "${workspaceFolder}" ] } } } ``` `--project-root` makes Ratel load the workspace's project and local configuration even when Cursor starts the process from another directory. User configuration always loads first; project and local entries override it by name. This direct `serve` path does not require the persistent daemon. Leave the package unversioned so Cursor and the configuration CLI follow the latest stable release. ### Restart and verify Open **Cursor Settings → Tools & MCP**. Confirm `ratel-local` is enabled and connected, then start a new chat. Cursor CLI users can inspect the same entry: ```bash cursor-agent mcp list cursor-agent mcp list-tools ratel-local ``` The tool list should include `search_capabilities` and `invoke_tool`. It also includes `get_skill_content` when Ratel loads at least one skill. ## Move existing Cursor servers Cursor's native MCP entries are not imported automatically. 1. Copy the current `.cursor/mcp.json` or `~/.cursor/mcp.json` somewhere safe. 2. Add each upstream to Ratel with `ratel-local mcp add`. 3. Add and verify the single `ratel-local` entry above. 4. Remove the migrated native entries from Cursor's `mcpServers` object. 5. Restart Cursor and begin a new chat. Leaving a migrated server both native and behind Ratel exposes duplicate tool schemas. Ratel's backup command does not cover Cursor files that you edit yourself. ## Verify the gateway Ask Cursor to call the discovery tool directly: ```text Call Ratel's search_capabilities tool with: {"query":"look up current React framework documentation","topKTools":3,"topKSkills":1} Return the raw result. ``` See [Progressive disclosure tools](https://www.ratel.sh/docs/progressive-disclosure-tools) for the request and response contract. If Cursor shows the server as connected but the tools are missing, toggle `ratel-local` off and on, then start a new chat. Continue with [Troubleshooting](https://www.ratel.sh/docs/local/troubleshooting#connector-or-daemon-failure). ## Next steps [MCP servers](https://www.ratel.sh/docs/local/mcp-servers) Configure scopes, transports, and upstream entries. [Managing skills](https://www.ratel.sh/docs/local/managing-skills) Put reusable playbooks behind the same searchable gateway. [Troubleshooting](https://www.ratel.sh/docs/local/troubleshooting) Diagnose project-root, networking, version, and upstream failures. --- # Daemon Source: https://www.ratel.sh/docs/local/daemon Ratel Local runs one daemon per user. Agent plugins start a lightweight `connect` bridge; the daemon owns upstream connections, project-scoped gateways, the browser UI, and control-plane writes. Sessions in the same canonical project can reuse upstream connections. User, project, and local configurations stay isolated, and an existing session keeps its acquired gateway generation while a newly connected session receives the latest revision. ## Recommended setup ```bash ratel-local setup ``` Setup installs a macOS launchd or Linux user-systemd service, starts it, replaces an incompatible version, and offers agent onboarding. Re-running setup is safe. Use daemon-only mode when agent configuration is managed elsewhere: ```bash ratel-local setup --daemon-only --yes ``` The default port is `5731`. Choose another port only on first installation: ```bash ratel-local setup --daemon-only --yes --port 7331 ``` ## Lifecycle commands ```bash ratel-local daemon status ratel-local daemon start ratel-local daemon stop ratel-local daemon restart ratel-local daemon open ``` `status` probes the configured loopback endpoint. A running response includes its pid, upstream count, active MCP-client count, and MCP endpoint. `open` creates a fresh authenticated browser session. The daemon listens on loopback; do not share the bearer-bearing session URL. Lower-level service commands are available for repair: ```bash ratel-local daemon install ratel-local daemon uninstall ``` `uninstall` stops and removes the login service. It does not delete Ratel configuration, OAuth state, backups, or saved Cloud settings. ## Foreground mode Run the daemon in the current terminal when a login service is unavailable: ```bash ratel-local daemon run --no-open --auto-config ``` Stop it with Control-C. `--port 0` is valid only for a foreground run; an installed service needs a stable port. ## Projects and reconnects The connector resolves a project from `--project-root`, `RATEL_PROJECT_ROOT`, `CLAUDE_PROJECT_DIR`, then its working directory. Inspect registered roots with: ```bash ratel-local project list ratel-local project add /path/to/project ratel-local project remove /path/to/project ``` `project remove` forgets the registry entry without deleting project files. Reconnect the affected agent after MCP, skill, retrieval, or OAuth changes. New sessions acquire the new immutable gateway generation; existing sessions drain on the old one. ## Upgrade or reconfigure Upgrade the global CLI, then run setup once: ```bash npm install --global @ratel-ai/ratel-local ratel-local setup ratel-local daemon status ``` Setup replaces an older daemon and reconciles the selected Claude Code or Codex plugin. Configuration remains in place. To change the installed port, recreate the service explicitly: ```bash ratel-local daemon uninstall ratel-local daemon install --port 7331 ``` Re-run `ratel-local setup --agent ` when you need to repair or add an agent link. ## Remove Ratel Local 1. Remove or disable the Ratel Local plugin or explicit MCP entry in each host. 2. Run `ratel-local statusline uninstall` if you installed the Claude Code statusline. 3. Remove scoped skills you no longer want Ratel to manage. 4. Run `ratel-local daemon uninstall`. 5. Run `npm uninstall --global @ratel-ai/ratel-local`. Your `~/.ratel` and project `.ratel` data remain for recovery. Back them up and remove them separately only when you no longer need configuration, OAuth state, or snapshots. ## Next steps [Browser UI](https://www.ratel.sh/docs/local/browser-ui) Open the daemon UI and inspect projects, clients, gateways, and settings. [Troubleshooting](https://www.ratel.sh/docs/local/troubleshooting) Diagnose a stopped, stale, or mismatched daemon. --- # MCP servers Source: https://www.ratel.sh/docs/local/mcp-servers Ratel Local reads Claude-Code-shaped `mcpServers` configuration. It connects each upstream and registers its tools in one [Ratel catalog](https://www.ratel.sh/docs/architecture). In the recommended setup, the persistent daemon owns these upstream connections. Each agent's `connect` bridge acquires the gateway for its canonical project. Its server instructions list the aggregated upstreams and tell the model to call `search_capabilities` before answering any substantive request — even one it could answer from general knowledge — and before declaring a capability or workflow unavailable. Only casual conversation, acknowledgments, clarifying questions, and plainly pure writing or reasoning skip the search. If one upstream fails to start, Ratel Local logs the failure and registers the rest. The gateway stays available. ## Configuration scopes Ratel Local merges three logical scopes. Later scopes win when the same server name appears more than once. | Scope | Path | Use it for | | --------- | ---------------------------------------- | ---------------------------------------------- | | `user` | `~/.ratel/config.json` | Servers available in every project | | `project` | `/.ratel/config.json` | Shared project configuration | | `local` | `/.ratel/config.local.json` | Private project overrides; add to `.gitignore` | The CLI defaults to `--scope user`. Use CLI commands instead of editing these files by hand. Project and local commands discover the project root from the current directory. Ratel prefers the nearest ancestor tree containing `pnpm-workspace.yaml`; otherwise it walks for `.git`, `.mcp.json`, or `package.json`. Run from inside the intended project. At runtime, entries merge in this order: user → project → local. A later entry replaces an earlier entry with the same name. The old `--scope global` alias is rejected; use `user`. ## Add an upstream The command shape mirrors `claude mcp add`. ```bash # stdio ratel-local mcp add --scope user airtable \ -e API_KEY=xyz -- npx -y airtable-mcp-server # HTTP ratel-local mcp add --scope user stripe \ https://mcp.stripe.com --transport http # SSE with a header ratel-local mcp add --scope project events \ https://example.com/sse --transport sse \ --header "Authorization: Bearer ${EVENTS_TOKEN}" ``` By default, `mcp add` connects once and stores the upstream's server instructions as its description. Pass `--description` to set one yourself or `--no-fetch-description` to skip the probe. For HTTP or SSE, skipping the probe also skips the initial OAuth attempt. `mcp add` refuses to replace a name in the target scope. Pass `--force` only when you intend to overwrite it; Ratel backs up the scope file first. Repeat `-e KEY=VALUE` for stdio environment variables or `--header "Name: Value"` for remote headers. ```json { "mcpServers": { "ev": { "type": "stdio", "command": "npx", "args": ["-y", "@modelcontextprotocol/server-everything"], "description": "filesystem and shell utilities" }, "remote": { "type": "http", "url": "https://example.com/mcp", "headers": { "Authorization": "Bearer ${API_TOKEN}" } } } } ``` `type` defaults to `stdio`. HTTP and SSE entries expand `${VAR}` placeholders in `url` and `headers` from the process environment. Descriptions help the agent understand each upstream before searching. They are not sent over the upstream transport. ### Supported fields | Transport | Required | Optional | | ------------- | --------- | ----------------------------------------------------------------------------- | | `stdio` | `command` | `args`, `env`, `cwd`, `description` | | `http`, `sse` | `url` | `headers`, `description`, `clientId`, `clientSecret`, `callbackPort`, `scope` | Unknown transport types remain in configuration but are skipped with a warning when the gateway starts. HTTP and SSE OAuth fields are covered on [OAuth upstreams](https://www.ratel.sh/docs/local/oauth). ## Inspect and change servers ```bash ratel-local mcp list ratel-local mcp get airtable ratel-local mcp get airtable --scope user # Interactive when no field flags are supplied ratel-local mcp edit --scope user --name airtable ratel-local mcp edit --scope user --name airtable \ --description "Airtable CRUD" ratel-local mcp edit --scope user --name airtable \ --env API_KEY=new-value --env OLD_KEY= ratel-local mcp remove --scope user --name airtable ``` Without `--scope`, `mcp get` resolves the most specific matching entry: local, then project, then user. Mutating commands default to user scope and require `--name` for `edit` and `remove`. Field edits support `--description`, `--type`, `--command`, repeated `--arg`, repeated `--env KEY=VALUE`, `--cwd`, `--url`, and repeated `--header KEY=VALUE`. An empty env or header value deletes that key. Use `--entry-json` for an entire entry; it cannot be combined with field flags. Every write snapshots the affected configuration first. See [Import or link](https://www.ratel.sh/docs/local/import-or-link) for backup locations and host migration. Add `"skills": { "dirs": ["..."] }` at the top level to use custom skill directories. Prefer the scoped registration commands on [Managing skills](https://www.ratel.sh/docs/local/managing-skills) instead of moving agent-owned folders by hand. ## Run Ratel Local yourself Linked hosts and the plugin use `connect` with the persistent daemon. Run `serve` yourself only for one-off operation, debugging, or embedding it in another process. ```bash ratel-local serve --config ~/.ratel/config.json ratel-local serve ~/.ratel/config.json ratel-local serve --auto-config ratel-local serve --auto-config --project-root /path/to/project ``` `--auto-config` cannot be combined with `--config`. Without `--project-root`, it checks `RATEL_PROJECT_ROOT`, then `CLAUDE_PROJECT_DIR`, then walks up from the current directory. Auto-config always loads the user path. When it finds a project root, it then loads the project and local paths; a missing file is treated as an empty configuration. The startup log prints the resolved root and every path. On success, stderr includes: ```text [ratel] ready, N upstream server(s) configured ``` `N` is the number of configured entries, not the number that connected. Read the earlier stderr lines for `failed to register`, `requires authorization`, or `unsupported transport` messages. ## Verify the gateway ### Check the configuration Run `ratel-local mcp list`. Confirm each upstream appears with the expected transport and authentication status. ### Search from the host Ask the host to call `search_capabilities` with a query such as `read a file`. A result contains tool hits grouped by upstream server and a separate skills bucket. ### Invoke a hit Call `invoke_tool` with a returned `toolId` and the tool arguments nested under `args`. Unknown ids return a structured error without crashing the server. The full tool schemas and result shapes live on [Progressive disclosure tools](https://www.ratel.sh/docs/progressive-disclosure-tools). At the MCP transport, every `tools/call` response JSON-serializes the result into `content[0].text`. Object results also appear in `structuredContent`. Failures return a structured `{ "error": "...", "isError": true }` payload instead of a protocol error. Ratel Local also registers the deprecated `search_tools` compatibility tool. New clients should use `search_capabilities`; the migration contract is on [Progressive disclosure tools](https://www.ratel.sh/docs/progressive-disclosure-tools). If you own the agent process and want to ingest an MCP server into an in-process catalog, use the inverse SDK path for [TypeScript](https://www.ratel.sh/docs/typescript/register-mcps) or [Python](https://www.ratel.sh/docs/python/register-mcps). ## Next steps [Import or link](https://www.ratel.sh/docs/local/import-or-link) Move existing host entries into these scopes or add Ratel Local alongside them. [OAuth upstreams](https://www.ratel.sh/docs/local/oauth) Authorize HTTP and SSE servers and recover when credentials expire. [Retrieval](https://www.ratel.sh/docs/local/retrieval) Configure model-free, semantic, or hybrid capability search by scope. --- # Retrieval Source: https://www.ratel.sh/docs/local/retrieval Ratel Local uses BM25 by default. It needs no model, download, or embedding request. Semantic and hybrid retrieval are explicit opt-ins. ## Inspect the effective mode ```bash ratel-local retrieval status ``` The command shows user, project, and local overrides, then the effective mode. The retrieval block is atomic: the narrowest scope that defines it replaces the entire earlier block. ## Configure or reset a scope ```bash # Keep every project model-free unless a narrower scope overrides it. ratel-local retrieval configure --scope user --method bm25 # Use the built-in model for one project. ratel-local retrieval configure \ --scope project \ --method hybrid \ --source built-in # Remove that override and inherit the earlier scope again. ratel-local retrieval reset --scope project ``` Writes use the same revision checks, transaction journal, and local Git-exclude safeguards as other scoped changes. ## Prepare a dense source Dense retrieval fails closed when its model or endpoint is unavailable. Preflight it before reconnecting the agent: ```bash # Prepare the effective merged configuration. ratel-local retrieval prepare # Prepare only the project override. ratel-local retrieval prepare --scope project ``` The CLI keeps an active preparation status on screen. A missing Hugging Face model shows the file, bytes transferred, and percentage before Ratel verifies it. The built-in source uses the SDK-pinned `BAAI/bge-small-en-v1.5` model. It downloads to the Hugging Face cache when prepared and uses roughly 130 MB of process memory while loaded. The model is shared by tool and skill catalogs. ## Other embedding sources Use a source that matches your language, network, and privacy requirements: ```bash # Hugging Face ratel-local retrieval configure --scope project --method hybrid \ --source huggingface --model intfloat/multilingual-e5-small --revision main --download # Local model directory ratel-local retrieval configure --scope local --method semantic \ --source local --model ~/.cache/models/bge-small # Ollama ratel-local retrieval configure --scope project --method semantic \ --source ollama --model nomic-embed-text # OpenAI-compatible endpoint ratel-local retrieval configure --scope project --method hybrid \ --source endpoint --url https://api.example.com/v1/embeddings \ --model text-embedding-3-small --api-key-env EMBEDDING_API_KEY ``` Literal endpoint API keys are rejected. `--api-key-env` names an environment variable the daemon must receive. * Built-in, Hugging Face, and local sources keep metadata and queries on the machine. * Ollama sends one preflight and retrieval requests to the configured local service. * Endpoint sources send tool and skill metadata while indexing and queries while searching. ## Apply the change Reconnect the affected agent after `configure`, `reset`, `prepare`, or dense OAuth changes. The new connection acquires the latest gateway generation; existing sessions continue on their old immutable generation until they disconnect. Use **Settings** in the [daemon UI](https://www.ratel.sh/docs/local/browser-ui) for the same validated save and preflight flow. ## Roll back to BM25 Configure BM25 at the scope that currently owns the dense override, then reconnect: ```bash ratel-local retrieval configure --scope project --method bm25 ``` ## Next steps [Retrieval and search](https://www.ratel.sh/docs/retrieval-and-search) Understand BM25, semantic ranking, hybrid fusion, and recall tradeoffs. [Troubleshooting](https://www.ratel.sh/docs/local/troubleshooting) Recover from a missing model, endpoint, or stale gateway generation. --- # Import or link Source: https://www.ratel.sh/docs/local/import-or-link Use `import` to move existing host servers behind Ratel Local. Use `link` when Ratel is already configured and you only need to connect the host. For first-time onboarding, run `ratel-local setup`. It prepares the daemon, links the agents you select, then offers import as a separate reviewed step. | Starting point | Command | Result | | ----------------------------------------------------- | --------- | --------------------------------------------------------------------------------------- | | Native MCP servers or skills should move behind Ratel | `import` | Moves selected entries into Ratel and connects the agent when needed | | Ratel scopes are already configured | `link` | Installs or enables the Ratel Local plugin; falls back to an MCP entry only when needed | | First-time setup | `setup` | Prepares the daemon, links selected agents, and offers a reviewed import | | Fresh Ratel configuration | `mcp add` | Adds upstreams directly to Ratel; no host rewrite | > **The plugin is the preferred integration** > > Install the Ratel Local plugin for Claude Code or Codex. It bundles the gateway and > agent skills. Its MCP entry runs the lightweight `connect` bridge to the persistent > daemon. An enabled plugin is already a Ratel connection, so import does not add a > second gateway. `link` reconciles and reinstalls the stable plugin when needed; it also > re-enables a disabled Codex plugin MCP server. ## Supported hosts and files Import and link have native adapters for Claude Code and Codex only. | Host scope | Claude Code | Codex | | ---------- | ---------------------------------------------------------- | ---------------------------------- | | User | `~/.claude.json` | `~/.codex/config.toml` | | Project | `/.mcp.json` | `/.codex/config.toml` | | Local | `projects[].mcpServers` in `~/.claude.json` | Not supported by the adapter | Without `--agent`, auto-detection chooses the first supported host with native entries: Claude Code, then Codex. Pass `--agent` when both are configured. Cursor and other MCP clients use [manual host configuration](https://www.ratel.sh/docs/local/cursor). ## Import native servers `ratel-local import --agent claude-code` imports selected Claude Code MCP servers and native skills into matching Ratel scopes. Run the wizard from the project where you use the native servers. ```bash ratel-local import ratel-local import --agent claude-code ratel-local import --agent codex ratel-local import --agent codex --dry-run ``` `--dry-run` prints planned writes and does not change files. The interactive flow may still start upstreams to read their instructions before it renders the plan. `--yes` selects every candidate, skips those probes and prompts, and keeps existing Ratel entries when names conflict. If the source agent is not linked, the wizard first offers to link Ratel Local, continue without linking, or cancel. Linking installs the agent plugin when possible; only a failed plugin installation falls back to an explicit MCP gateway entry. The import then has these stages: 1. Select native MCP entries and move them into matching Ratel scopes. 2. Select native skills to manage as invoke-only. 3. Remove the selected MCP entries from the source agent. The final import commit asks for confirmation. Claude Code then offers a separate, skippable statusline installation step. If you continue without linking, imported MCPs and skills are not usable from that source agent until you run `link` later. Entries you do not select stay native. Claude Code scopes are preserved. Codex preserves user and project scopes; its adapter has no local scope. When the same name appears at multiple scopes, the most specific candidate wins: local, then project, then user. ### Resolve name conflicts Choose how imports handle a server name that already exists in Ratel. | Strategy | Behavior | | -------------------- | ---------------------------------------------------- | | `add-missing-only` | Keep the Ratel entry and skip the incoming duplicate | | `replace-selected` | Ask which selected duplicates to replace | | `replace-from-agent` | Replace duplicates with the host versions | ```bash ratel-local import --agent codex \ --conflict-strategy add-missing-only ``` `replace-selected` is interactive. It cannot be combined with `--yes` or `--dry-run`. > **Review converted entries before committing** > > Ratel Local runs `stdio`, `http`, and `sse`. Claude Code's > `streamable-http` alias and helper-specific fields are not normalized during import. > Codex-only controls such as `env_vars`, timeouts, tool filters, approval modes, and > OAuth tokens are not migrated. > > Inspect with `mcp get`, correct the entry, and reauthorize remote servers before > committing the import. ## Before and after Stage one copies the selected entry into the matching Ratel scope: ```json { "mcpServers": { "context7": { "type": "stdio", "command": "npx", "args": ["-y", "@upstash/context7-mcp"] } } } ``` When plugin installation is unavailable, linking or importing with link enabled writes one reviewed fallback connector entry. The generated command and absolute path depend on your global installation and host scope. **Claude Code** ```diff { "mcpServers": { - "context7": { - "type": "stdio", - "command": "npx", - "args": ["-y", "@upstash/context7-mcp"] + "ratel-local": { + "type": "stdio", + "command": "/absolute/path/to/ratel-local", + "args": ["connect", "--agent-host", "claude-code", "--link-scope", "user"] } } } ``` **Codex** ```diff -[mcp_servers.context7] -command = "npx" -args = ["-y", "@upstash/context7-mcp"] +[mcp_servers.ratel-local] +command = "/absolute/path/to/ratel-local" +args = ["connect", "--agent-host", "codex", "--link-scope", "user"] +enabled = true ``` ## Link without migrating ```bash ratel-local link --agent claude-code ratel-local link --agent codex ``` `link` installs or reconciles the stable Ratel Local plugin first. A disabled Codex plugin MCP server is re-enabled. If a new plugin installation fails, `link` writes a `ratel-local` fallback connector entry. A failed reconciliation of an existing plugin stops instead of silently replacing a working connection. Native MCP entries are not moved or removed. Stable releases follow the marketplace's default `main` branch. Do not add a branch, tag, or `--ref` override to a stable link. `link` supports `--yes` but has no dry-run mode. Use the interactive preview when you want to inspect every planned host write. Restart the host after an import or link. Then confirm the entry: **Claude Code** ```bash claude mcp get ratel-local ``` **Codex** ```bash codex mcp get ratel-local --json ``` ## Backups and rollback `add`, `edit`, `remove`, `import`, and `link` snapshot every file they change under a timestamped directory in `~/.ratel/backups/` before writing. Scoped mutations are revision-checked and journaled. A failed multi-file commit restores the files written by that commit when restoration is supported. ```bash ratel-local backup list ls -1 ~/.ratel/backups ``` `backup list` prints only the creation time, action, and file count. Open the matching `manifest.json` to find each `originalPath`, hashed `backupPath`, and `existedBefore` value. An import can create separate backup sets for its Ratel and host stages. There is no restore command. To roll back, stop the host, save the current file elsewhere, copy each required `backupPath` to its `originalPath`, and restart the host. When `existedBefore` is false, no older body exists. Save the new file elsewhere, then remove it to restore the original state. A failed multi-file stage restores files it already wrote, but it does not reverse an earlier completed import stage. ## Next steps [MCP servers](https://www.ratel.sh/docs/local/mcp-servers) Inspect scopes, add upstreams, and run the gateway directly. [Quickstart](https://www.ratel.sh/docs/local/quickstart) Follow the full Claude Code or Codex setup and verification path. [Cursor](https://www.ratel.sh/docs/local/cursor) Move Cursor entries manually and add one standard stdio gateway. [Troubleshooting](https://www.ratel.sh/docs/local/troubleshooting) Recover from conversion, scope, duplicate-registration, or rollback problems. --- # Browser UI Source: https://www.ratel.sh/docs/local/browser-ui Ratel Local serves the browser UI from the persistent daemon. Run setup first, then open a fresh authenticated session: ```bash ratel-local daemon open # Equivalent shortcut ratel-local ui ``` `ratel-local ui --no-open` prints the session URL instead. `ui --port` is no longer supported; choose the daemon port during first setup or service installation. ## What you can manage * See registered projects, connected MCP clients, and live user/project gateways. * Add, edit, remove, and authorize MCP servers by scope. * Import or link Claude Code and Codex, including duplicate-installation repair. * Import and register skills by user, project, or local scope. * Configure and preflight BM25, semantic, or hybrid retrieval under **Settings**. * Install or remove the Claude Code statusline. * Inspect backup metadata for recoverable writes. When the experimental Cloud telemetry feature is enabled on the daemon, **Settings** also shows Ratel Cloud credentials and native Claude Code/Codex exporter controls. Those controls stay absent while the feature gate is off. See [Experimental Cloud telemetry](https://www.ratel.sh/docs/local/cloud-telemetry). ## Agent Setup Open the agent page for Claude Code or Codex to inspect its connection and native MCP entries. * **Link** installs or reconciles the stable Ratel Local plugin. It uses the reviewed connector fallback only when a new plugin installation fails. * **Import** previews MCP and skill changes before a revision-checked commit. * **Fix duplicate installation** removes a recognized explicit Ratel entry when the plugin is already active. * **Switch to plugin** removes the explicit fallback only after plugin installation succeeds. Unrelated MCP entries are preserved. Cursor is not adapted here; use the [manual Cursor guide](https://www.ratel.sh/docs/local/cursor). ## Settings and reconnects Retrieval settings have one validated save flow. A missing Hugging Face model requires explicit download confirmation; the dialog shows actual bytes and percentage before verification and commit. Reconnect an agent after changing MCPs, skills, retrieval, or OAuth. New clients acquire the new gateway generation while existing clients finish on the revision they already hold. ## Backups Configuration changes use the same revision checks, journal, and backup path as the CLI. The UI does not have a general restore button. ```bash ratel-local backup list ``` Open the matching `~/.ratel/backups//manifest.json` and follow [Backups and rollback](https://www.ratel.sh/docs/local/import-or-link#backups-and-rollback) when manual recovery is required. ## Security boundary The daemon binds to `127.0.0.1`. API requests require the daemon credential or a short-lived UI session token. Treat the URL printed by `ui --no-open` as a credential. Do not forward, publish, or save it in project files. ## Next steps [Daemon](https://www.ratel.sh/docs/local/daemon) Manage the service, projects, status, upgrades, and removal. [Retrieval](https://www.ratel.sh/docs/local/retrieval) Review source, memory, privacy, and reconnect tradeoffs. [Managing skills](https://www.ratel.sh/docs/local/managing-skills) Understand scoped reference and copy registrations. --- # Managing skills Source: https://www.ratel.sh/docs/local/managing-skills Ratel Local serves skills through the same catalog as tools. The agent finds them in the `search_capabilities` skills bucket and loads one with `get_skill_content`. Stable releases use scoped registrations. They no longer move skills through the deprecated `skill activate` and `skill deactivate` commands. ## Registration model Register a skill at user, project, or local scope: * `reference` keeps the source folder in place and records its canonical path. * `copy` creates a Ratel-owned copy for the selected scope. Project and local registrations never edit repository-owned skill metadata. The normal scope precedence applies when the same skill id appears more than once. ## Discover and import skills Inspect candidates from supported Claude Code and Codex locations: ```bash ratel-local skill list --discovered ratel-local skill list --discovered --project /path/to/project ``` Import interactively, or name candidates directly: ```bash ratel-local skill import --scope user --mode reference ratel-local skill import deploy-runbook --scope project --mode copy ratel-local skill import --scope local --mode reference --dry-run ``` `--dry-run` previews the registration. `--yes` skips confirmation. When two hosts expose the same skill id, import keeps the first deterministic candidate and reports later duplicates as skipped. ## Inspect registrations ```bash # Effective catalog for the current context ratel-local skill list # Every registration, including shadowed or invalid entries ratel-local skill list --configured # Select a registered project explicitly ratel-local skill list --configured --project /path/to/project ``` Add another scope for an effective skill: ```bash ratel-local skill add-scope deploy-runbook \ --scope project --mode reference ``` ## Remove a registration Use `remove-scope` to stop using the selected registration without deleting an owned copy. Use `remove` when a Ratel-owned copy should also be deleted: ```bash ratel-local skill remove-scope deploy-runbook --scope project ratel-local skill remove deploy-runbook --scope project ``` Both commands confirm by default and support `--dry-run` and `--yes`. ## Legacy migration and recovery The daemon safely migrates verified legacy skill links to user-scoped references on startup. Ambiguous or externally changed entries are left untouched with diagnostics. ```bash ratel-local doctor ratel-local doctor --fix ``` `doctor --fix` performs the same recoverable migration for safe entries. Review its diagnostics before changing ambiguous paths manually. ## Claude Code preload hook Capability search is the pull path: the agent searches when it needs a playbook. The optional Claude Code `UserPromptSubmit` hook adds a push path for terse prompts. ```bash ratel-local skill install-hook --scope user ratel-local skill install-hook --scope project ratel-local skill uninstall-hook --scope user ``` The hook invokes `ratel-local skill preload-hook`, ranks the effective skills for the prompt, and injects a pointer only when one skill clearly wins. It fails open and never blocks a prompt. The executable resolves through `RATEL_LOCAL_BIN`, then `PATH`, then an interactive prompt. Installation is idempotent, revision-checked, and backed up. ## Debug ranking ```bash ratel-local skill suggest \ --prompt "build a login form" \ --cwd . \ --limit 3 ``` Use `--format json` for structured output, `--min-score` to drop weak hits, and repeat `--dir` only when you intentionally want to rank an explicit directory set instead of the effective scoped catalog. Ranking uses the configured retrieval method. With default BM25 it stays model-free; semantic and hybrid behavior follows the selected [retrieval scope](https://www.ratel.sh/docs/local/retrieval). ## Apply changes to an agent Reconnect the affected agent after importing, adding, or removing a skill registration. Its next connection receives a new gateway generation with the updated catalog. ## Next steps [Tools, MCP & Skills](https://www.ratel.sh/docs/tools-mcp-skills) Learn how skills retrieve and what belongs in `SKILL.md`. [Retrieval](https://www.ratel.sh/docs/local/retrieval) Choose BM25, semantic, or hybrid ranking for tools and skills. [Browser UI](https://www.ratel.sh/docs/local/browser-ui) Review and apply the same scoped skill flows visually. --- # OAuth upstreams Source: https://www.ratel.sh/docs/local/oauth HTTP and SSE upstreams can authorize through Ratel Local's loopback OAuth 2.1 and PKCE flow. `mcp add` starts the flow when the server requires it. ```bash ratel-local mcp add --scope user stripe \ https://mcp.stripe.com --transport http ``` OAuth state is scoped to the configuration entry that owns the upstream. User stores live under `~/.ratel/oauth/user/`; project and local stores live under the matching project identity in `~/.ratel/oauth/projects/`. The daemon reuses that state across agent sessions without mixing credentials between user, project, and local entries that share a server name. Ratel Local creates the directory with mode `0700` and token files with mode `0600` where the operating system supports those permissions. Treat the files as secrets. For an upstream without dynamic client registration, provide its client details: ```bash ratel-local mcp add --scope user private-api \ https://mcp.example.com --transport http \ --client-id "$OAUTH_CLIENT_ID" \ --client-secret "$OAUTH_CLIENT_SECRET" \ --oauth-scope "mcp:tools" ``` Use `--callback-port` when the provider requires a fixed loopback redirect port. > **Static client secrets are configuration** > > `--client-secret` is stored as plain text in Ratel config and can appear in config > backups. Restrict access to those files and prefer dynamic client registration when > the provider supports it. ## Check or renew authorization ```bash ratel-local mcp auth stripe ratel-local mcp auth ratel-local mcp auth --check ratel-local mcp list ``` `mcp auth ` tries a refresh token before opening a browser. Without a name, it authorizes every upstream currently marked as needing authentication. `--check` reports status from locally stored token metadata without contacting the provider. `mcp list` shows one of these values for each entry: | Status | Meaning | | ------------- | ------------------------------------------------------------------------------------------------ | | `ok` | An access token exists and its recorded expiry has not passed; this is not a live provider check | | `expired` | The recorded access-token expiry has passed | | `needs auth` | Start or repeat the authorization flow | | `unsupported` | Dynamic client registration was rejected; a configured static client may still work | | `n/a` | Authentication does not apply | When a gateway generation starts, Ratel Local checks stored tokens for HTTP and SSE upstreams. It refreshes a token within 60 seconds of expiry. A failed refresh clears the stored tokens so the next authorization starts cleanly. An access-token-only file cannot be refreshed at startup, even while its recorded access token is still fresh. Ratel Local marks that upstream as needing authentication. Run `ratel-local mcp auth ` to establish a complete token set. If dynamic client registration is unsupported, add the upstream again with `--client-id` and, when required, `--client-secret`. A named `mcp auth` attempt waits up to five minutes for the loopback callback after opening the browser. The daemon migrates unambiguous legacy `~/.ratel/oauth/.json` files to their scoped destinations. Run `ratel-local doctor` for unresolved legacy state; use `ratel-local doctor --fix` only after reviewing the diagnostics. ## Recover from a 401 A live upstream 401 returns a structured `needs_auth` error from `invoke_tool`. The response includes a recovery hint and the upstream name when Ratel Local can infer it. The host can then call Ratel Local's `auth` capability tool. Pass an upstream name to authorize one server or omit it to handle every server that needs authentication. The tool returns a row per upstream with `authorized`, `skipped`, `failed`, or `unsupported`. Failed rows include the message in `reason` instead of becoming MCP protocol errors. The tool description lists the upstreams that currently need authentication. The model can see what to repair before it calls the tool. If a 401 persists: 1. Run `ratel-local mcp auth --check` and then `ratel-local mcp auth `. 2. Confirm the configured OAuth scopes and static client details match the provider. 3. Reconnect the agent so it acquires a new gateway generation with the renewed state. 4. Inspect [local telemetry](https://www.ratel.sh/docs/local/statusline-telemetry) for authorization and upstream errors. The shared capability result contract lives on [Progressive disclosure](https://www.ratel.sh/docs/optimization#progressive-disclosure). OAuth lifecycle events use the vocabulary on [Telemetry](https://www.ratel.sh/docs/observability). ## Use the browser UI The [Browser UI](https://www.ratel.sh/docs/local/browser-ui) can start the same upstream authorization flow. It remains bound to loopback and requires the session token in its launch URL. ## Next steps [MCP servers](https://www.ratel.sh/docs/local/mcp-servers) Configure HTTP and SSE transports and inspect upstream status. [Statusline & local telemetry](https://www.ratel.sh/docs/local/statusline-telemetry) Find the local authorization events Ratel records. --- # Statusline & local telemetry Source: https://www.ratel.sh/docs/local/statusline-telemetry Ratel Local records local operational events as JSONL. Claude Code can use that stream to show session activity and estimated context savings in its statusline. This local sink is separate from the default-off [experimental Cloud telemetry](https://www.ratel.sh/docs/local/cloud-telemetry) feature. Local JSONL does not enable Cloud export. [Ratel SDK telemetry](https://www.ratel.sh/docs/observability) owns the event vocabulary and reliability contract. This page covers the files and commands Ratel Local operates. ## What gets written By default, each Ratel Local gateway generation writes one JSON object per line to: ```text ~/.ratel/telemetry//.jsonl ``` `` is the gateway process's absolute working directory with `/` and `.` replaced by `-`. Each gateway launch creates a new timestamped session file. Events cover searches, invocations, upstream calls, authorization, and an estimate of each upstream's tool-definition payload. Control the sink at gateway startup: ```bash # Exact output file ratel-local serve --telemetry-file /secure/path/ratel.jsonl # Different root for per-project buckets RATEL_TELEMETRY_DIR=/secure/ratel-telemetry ratel-local serve --auto-config # Disable it ratel-local serve --telemetry off ratel-local serve --no-telemetry RATEL_TELEMETRY=off ratel-local serve --auto-config ``` The statusline discovers only the per-project bucket under `~/.ratel/telemetry`, or under `RATEL_TELEMETRY_DIR` when Claude Code inherits that variable. It does not discover an arbitrary `--telemetry-file`. > **Keep the project buckets aligned** > > The gateway writes to a bucket derived from its process working directory. The > statusline reads the bucket derived from Claude Code's `workspace.project_dir`. > > If the paths differ, the statusline says `waiting for Ratel telemetry`. Start the > gateway from the project root, even when you also pass `--project-root`. ## Read the statusline `ratel-local statusline` reads Claude Code's statusline JSON from stdin and prints two rows. With ANSI colors removed, output resembles: ```text ● Claude Opus 50k / 200k · 25% 10m ⎇ main █████░░░░░░░░░░░░░ Ratel on saves ~1k (2 tools) ``` | Part | Meaning | | ---------------------------- | ------------------------------------------------------------------- | | dot and `Ratel on` | Claude Code has a Ratel MCP entry or enabled Ratel plugin | | model, context, time, branch | values from Claude Code's statusline payload and current Git branch | | bar | current context-window usage | | `saves ~…` | estimated upstream tool-definition tokens hidden behind Ratel | | `could trim ~…` | the same estimate while Ratel is not enabled in Claude Code | The savings number is an estimate, not measured provider billing. Ratel serializes the upstream tool definitions and estimates one token per four UTF-8 bytes. Older telemetry without payload size falls back to 130 tokens per tool. Missing telemetry prints `waiting for Ratel telemetry`. Malformed Claude input fails open with loading and unavailable lines; it does not block Claude Code. ## Install, replace, or remove it ```bash ratel-local statusline install ratel-local statusline install --force ratel-local statusline uninstall ``` Install and uninstall confirm before writing; pass `--yes` to skip confirmation. Install writes a command statusline with zero padding and a 30-second refresh interval to `~/.claude/settings.json`. It refuses to replace another tool's statusline unless you pass `--force`. Uninstall removes only a Ratel-owned setting. Other Claude Code settings and statuslines remain untouched. After a successful Claude Code import, the interactive flow offers statusline setup as a separate skippable step. `import --yes` installs a missing statusline but leaves another tool's statusline unchanged. `link` never installs it. ## Retention and privacy The JSONL files stay local; this sink does not send them over the network. They can still contain search text, tool identifiers, error details, and operational metadata. Protect the telemetry directory like other agent logs and review it before sharing. Ratel Local does not prune old session files. Stop the gateway before deleting files you no longer need. Disabling the sink stops new local events; it does not delete existing files, so the statusline can continue showing the latest stored estimate. ## Next steps [Telemetry semantics](https://www.ratel.sh/docs/observability) Read the event schema, lifecycle, sampling, and reliability contract. [OAuth upstreams](https://www.ratel.sh/docs/local/oauth) Recover from authorization failures represented in the local event stream. [Import or link](https://www.ratel.sh/docs/local/import-or-link) See when host wiring installs the Claude Code statusline. [Experimental Cloud telemetry](https://www.ratel.sh/docs/local/cloud-telemetry) Review the separate feature gate, privacy levels, and native exporters. --- # Experimental Cloud telemetry Source: https://www.ratel.sh/docs/local/cloud-telemetry > **Experimental and off by default** > > Stable Ratel Local does not send Cloud telemetry by default. Only the exact daemon > startup value `RATEL_FEATURE_CLOUD_TELEMETRY=1` enables this experimental integration. The feature covers three independent streams: * native Claude Code traces and structured logs; * native Codex traces and structured logs; * traces from the Ratel SDK runtime hosted by the daemon. They use one daemon-owned loopback relay and Cloud credential. Ratel Local does not merge or correlate their trace ids. ## Enable the daemon-wide gate For a new installation: ```bash RATEL_FEATURE_CLOUD_TELEMETRY=1 ratel-local setup ``` Setup copies the enabled flag into a new macOS launchd or Linux systemd service. A plain restart does not rewrite an existing service definition. Enable an existing service by reinstalling it: ```bash ratel-local daemon uninstall RATEL_FEATURE_CLOUD_TELEMETRY=1 ratel-local daemon install ``` To return to stable default behavior, reinstall without the environment assignment: ```bash ratel-local daemon uninstall ratel-local daemon install ``` These lifecycle commands preserve Ratel configuration and saved Cloud settings. Saved credentials never enable the feature by themselves. ## Configure Ratel Cloud Open `ratel-local daemon open`, then use **Settings → Ratel Cloud**. Enter the trace endpoint and an `rtl_...` API key. The daemon stores them in `~/.ratel/cloud-traces.json` with user-only permissions. The authenticated UI returns only the endpoint and whether a key exists. It never reads the saved key back to the browser or CLI. Native agent configuration contains only the daemon's loopback route, never the Cloud key. Sign in to [Ratel Cloud](https://cloud.ratel.sh), then create a key in Settings. Enter it through the CLI's masked prompt or the UI password field; do not put it in project configuration, agent configuration, command history, or chat. ## Inspect or change native exporters Check status before changing an agent: ```bash ratel-local traces status ratel-local traces status --agent codex --json ``` Enable the safest level explicitly for one or both hosts: ```bash ratel-local traces enable --agent claude-code --agent codex ratel-local traces disable --agent codex ``` Plain `enable` selects **Redacted**. | Host | Available levels | | ----------- | --------------------------------------------- | | Claude Code | `redacted`, `tool-details`, `full-content` | | Codex | `redacted`, `tool-activity`, `prompt-content` | All enhanced levels can contain sensitive material. Interactive flows show a warning and ask for consent. Non-interactive content-bearing setup must be fully explicit: ```bash ratel-local traces enable --agent claude-code --level full-content \ --confirm-content --yes ``` Ratel-managed Claude levels always set raw API body capture off. Codex Tool activity may include output snippets even when prompt logging is off. If status reports a conflicting exporter, Ratel Local does not replace it by default. Interactive overwrite warns that no backup is retained. Automation requires both `--overwrite` and `--yes`. ## Setup automation Interactive `setup` offers tracing only when the feature gate is enabled. Plain `setup --yes` always skips it. ```bash RATEL_FEATURE_CLOUD_TELEMETRY=1 \ ratel-local setup --yes --traces --agent claude-code --agent codex ``` `--overwrite-traces` is available only with `--traces` and should be used only for a known conflicting exporter. ## Privacy-safe relay behavior The daemon accepts OTLP/HTTP protobuf only on loopback and forwards the payload bytes unchanged over HTTPS with the daemon-held credential. It does not decode, filter, enrich, join, or persist a retry queue for native payloads. Cloud failures return an error to the exporter. Invalid Cloud settings or telemetry provider initialization fail open: the daemon UI, MCP endpoint, and Local gateway remain available. When the gate is off, the relay routes and mutation controls are unavailable. An unset flag, `0`, or any value other than `1` keeps all experimental Cloud behavior off. ## Local telemetry remains separate The JSONL stream used by the Claude Code statusline stays on the machine and has its own retention controls. See [Statusline & local telemetry](https://www.ratel.sh/docs/local/statusline-telemetry). ## Next steps [Daemon](https://www.ratel.sh/docs/local/daemon) Manage the service definition and confirm the running version. [Troubleshooting](https://www.ratel.sh/docs/local/troubleshooting) Diagnose a disabled gate, stale port, conflict, or missing Cloud setting. --- # Embed as a library Source: https://www.ratel.sh/docs/local/library `@ratel-ai/ratel-local` can run inside your process. Use it when you want Ratel Local's upstream aggregation but need to own startup, transport, or shutdown. This is the inverse of the SDK's `registerMcpServer`: the SDK ingests an MCP server into a catalog, while this package exposes a catalog as an MCP server. See the ingestion guides for [TypeScript](https://www.ratel.sh/docs/typescript/register-mcps) or [Python](https://www.ratel.sh/docs/python/register-mcps). ## Install ```bash pnpm add @ratel-ai/ratel-local @modelcontextprotocol/sdk ``` The key entry points are summarized in the [`@ratel-ai/ratel-local` package reference](https://www.ratel.sh/docs/packages/typescript/mcp-server). ## Build and serve a gateway `buildGatewayFromConfig` connects the configured upstreams and builds their catalog. `createMcpServer` exposes that catalog's capability tools over an MCP transport. ```ts import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { buildGatewayFromConfig, createMcpServer, } from "@ratel-ai/ratel-local"; const gateway = await buildGatewayFromConfig({ mcpServers: { context7: { type: "stdio", command: "npx", args: ["-y", "@upstash/context7-mcp"], }, }, }); const server = await createMcpServer(gateway.catalog, { name: "my-gateway", version: "0.1.0", transport: new StdioServerTransport(), skillCatalog: gateway.skillCatalog, upstreamServers: gateway.upstreamServers, runAuthFlow: gateway.runAuthFlow, }); gateway.setListChangedNotifier(server.notifyToolListChanged); const shutdown = async () => { try { await server.close(); } finally { await gateway.close(); } }; process.once("SIGINT", () => void shutdown()); process.once("SIGTERM", () => void shutdown()); ``` The optional server fields mirror the CLI's `serve` wiring: * A non-empty `skillCatalog` registers `get_skill_content`. * `runAuthFlow` registers the `auth` capability tool. * `upstreamServers` lists aggregated upstreams in the server instructions. `buildGatewayFromConfig` degrades per upstream: it logs an unsupported transport, connection failure, or authorization requirement and continues building. Supply `logger` in its options to route those messages into your application's logging. `gateway.upstreamServers` contains connected upstreams plus entries marked `needsAuth`; unsupported and other failed entries are absent. It is not proof that every config entry started. ## Keep the tool list current The server handle exposes `notifyToolListChanged()`. Call it after a runtime change that alters the capability tools visible to the connected client. ## Close both layers `server.close()` closes the downstream MCP server and its transport. `gateway.close()` closes the upstream connections created by `buildGatewayFromConfig`. Always close both, and put `gateway.close()` in a `finally` block. Closing only the server can leave upstream child processes or network connections running. ## Next steps [MCP servers](https://www.ratel.sh/docs/local/mcp-servers) Learn the configuration shape consumed by `buildGatewayFromConfig`. [Architecture](https://www.ratel.sh/docs/architecture) Follow the shared catalog, retrieval, and invocation flow. [Progressive disclosure tools](https://www.ratel.sh/docs/progressive-disclosure-tools) Read the capability-tool schemas exposed by the server. --- # Troubleshooting Source: https://www.ratel.sh/docs/local/troubleshooting Ratel Local has three layers: the agent starts a connector, the connector attaches to the per-user daemon, and the daemon builds a scoped gateway from upstream MCP servers. Check them in that order. ## First checks ```bash ratel-local --version ratel-local daemon status ratel-local mcp list ``` The command should print a version number. If the daemon is missing, stopped, or older, run: ```bash ratel-local setup ``` Then restart the agent and inspect its Ratel entry: **Claude Code** ```bash claude mcp get ratel-local ``` Run `/reload-plugins` or restart Claude Code. **Codex** ```bash codex mcp get ratel-local --json ``` Confirm the plugin MCP server is enabled, then start a new session. **Cursor** ```bash cursor-agent mcp list cursor-agent mcp list-tools ratel-local ``` Cursor uses the [manual direct-server setup](https://www.ratel.sh/docs/local/cursor), not the setup adapter. ## Only daemon bootstrap tools appear When `connect` cannot attach, it still exposes `ratel_daemon_status`, `ratel_daemon_start`, and `ratel_daemon_setup`. 1. Call `ratel_daemon_status` to distinguish missing, stopped, and incompatible service state. 2. Use `ratel_daemon_start` for an installed stopped service. 3. Run the terminal command returned by `ratel_daemon_setup` when setup is required. Interactive setup never runs inside MCP stdio. ## Connector or daemon failure Reproduce the connector from the affected project: ```bash ratel-local connect --project-root "$PWD" ``` The command should print its resolved project scope and attach to the loopback daemon. If it reports a missing token or service, run `ratel-local setup`. Use direct serve only to isolate the gateway from the daemon and connector: ```bash ratel-local serve --auto-config --project-root "$PWD" ``` Stop it with Control-C before reconnecting the host. Daemon logs live under `~/.ratel/logs/`. `daemon status` also reports stale state when a recorded pid or port no longer answers. ## Duplicate Ratel connections An enabled plugin already supplies the connector. Do not keep a second explicit `ratel-local` host entry beside it. Open `ratel-local daemon open`, choose the affected agent, then use **Fix duplicate installation**. The repair removes only recognized Ratel entries and preserves unrelated MCP servers. Use **Switch to plugin** for an MCP-only installation. The explicit fallback remains until plugin installation succeeds. ## One upstream is missing Ratel Local keeps the gateway available when one upstream fails. Inspect the effective entry and authorization state: ```bash ratel-local mcp list ratel-local mcp get ratel-local mcp auth --check ``` Common causes: | Finding | Action | | ------------------------------------- | ------------------------------------------------------------------------------ | | startup command fails | run the upstream command directly; fix executable, args, `cwd`, or environment | | `requires authorization` | run `ratel-local mcp auth ` | | unsupported transport | use `stdio`, `http`, or `sse` | | entry is shadowed | inspect user, project, and local definitions with `mcp list` | | imported host-only fields are missing | edit the Ratel entry, then reauthorize if needed | Reconnect the agent after the correction. ## Wrong project or scope The daemon loads these scopes in order: ```text ~/.ratel/config.json /.ratel/config.json /.ratel/config.local.json ``` Later scopes replace matching names. Check the registered root and connector resolution: ```bash ratel-local project list ratel-local connect --project-root /absolute/path/to/project ``` The connector otherwise checks `RATEL_PROJECT_ROOT`, `CLAUDE_PROJECT_DIR`, then its working directory. Set `RATEL_PROJECT_ROOT` in a manual host entry when the host does not provide a useful project directory. Run project/local mutations from inside the intended project or pass `--project` where the command supports it. ## Dense retrieval failure Semantic and hybrid gateway startup fails closed when the model or endpoint is not ready. ```bash ratel-local retrieval status ratel-local retrieval prepare --scope project ``` Fix the model, endpoint, or API-key environment, then reconnect the agent. To return to the model-free default: ```bash ratel-local retrieval configure --scope project --method bm25 ``` Use the scope that owns the active override. See [Retrieval](https://www.ratel.sh/docs/local/retrieval). ## Cloud telemetry is unavailable Cloud telemetry is experimental and off by default. `traces` mutations require the running daemon to have `RATEL_FEATURE_CLOUD_TELEMETRY=1` persisted in its service definition. ```bash ratel-local traces status ratel-local daemon status ``` Exporting the flag before `daemon restart` does not update an installed service. Reinstall it with the gate: ```bash ratel-local daemon uninstall RATEL_FEATURE_CLOUD_TELEMETRY=1 ratel-local daemon install ``` Status `stale` means the managed exporter points to an old daemon port; `traces enable` can repair it. Status `conflict` means another exporter owns the setting. Replacement is irreversible and requires explicit confirmation or both `--overwrite --yes`. See [Experimental Cloud telemetry](https://www.ratel.sh/docs/local/cloud-telemetry) before enabling any content-bearing level. ## First-run networking The first exact `npx` launch may download the package: ```bash npm view @ratel-ai/ratel-local version npx -y @ratel-ai/ratel-local --version ``` If either fails, fix registry, proxy, DNS, or certificate access in the environment the host inherits. `mcp add` can also probe a new upstream for its description; run the upstream command directly when that stage stalls. ## Version mismatch Compare every executable that can win on `PATH` with the stable package pin used by the plugin or a manual host entry: ```bash which -a ratel-local ratel-local --version npx -y @ratel-ai/ratel-local --version ``` Keep one stable installation path, run `ratel-local setup`, and restart the host. Stable plugin setup follows the repository's default `main` branch; remove any branch, tag, or `--ref` override. ## Interrupted changes or legacy state ```bash ratel-local doctor ratel-local doctor --fix ratel-local backup list ``` `doctor` diagnoses transaction recovery, project state, scoped OAuth state, and legacy skill links. `--fix` applies only recoverable repairs. For a manual rollback, inspect the matching `~/.ratel/backups//manifest.json`. Copy each required `backupPath` to its `originalPath`; remove a newly created file only when `existedBefore` is false. There is no general restore command. ## Update, reconfigure, or remove Upgrade the exact global package and re-run setup: ```bash npm install --global @ratel-ai/ratel-local ratel-local setup ``` Use `setup --agent ` to repair a host link. Use `daemon uninstall` followed by `daemon install --port N` to change the service port. For complete removal, follow the ordered flow on [Daemon](https://www.ratel.sh/docs/local/daemon#remove-ratel-local). Service removal preserves `~/.ratel` for recovery. ## Next steps [Daemon](https://www.ratel.sh/docs/local/daemon) Review lifecycle, projects, reconnects, upgrades, and removal. [Import or link](https://www.ratel.sh/docs/local/import-or-link) Review conversion limits, conflicts, and backups. [OAuth upstreams](https://www.ratel.sh/docs/local/oauth) Inspect scoped token state and repeat authorization safely.