@ratel-ai/sdk
v0.9.1
Published
TypeScript SDK for Ratel — context engineering platform for AI agents. BM25 tool retrieval, MCP ingestion, framework-neutral capability tools.
Downloads
4,681
Readme
@ratel-ai/sdk retrieves the tools and skills relevant to each agent turn instead of sending the full catalog to the model. It bundles Ratel's Rust engine in-process: BM25 by default, with local semantic and hybrid retrieval available when needed. No API key, vector database, or service is required. Installing a published package on a supported prebuilt target also requires no Rust toolchain.
Use ToolCatalog for ranked tools with executable handlers and SkillCatalog for ranked playbooks loaded on demand. Expose searchCapabilitiesTool, invokeToolTool, and getSkillContentTool so an agent can discover tools and skills, invoke tools, and load full skill instructions. Tools from existing MCP servers can be ingested into the tool catalog. Experimental — facts: the experimental namespace adds experimental.FactCatalog for constant grounding content (a shop's address, a brand's voice). See Facts below. This API may change without a major-version bump.
Semantic and hybrid retrieval use a configurable embedding model (ADR 0012), set per catalog via the embedding option: the built-in default, a HuggingFace repo or local directory (in-process), or an OpenAI-compatible endpoint (OpenAI, Ollama, TEI, vLLM).
For semantic or hybrid retrieval, register() folds embedding in: it accepts one tool or a whole array and embeds on a libuv worker, so model loading, HTTP, and inference never block Node's event loop — and embedding errors surface right at register():
const catalog = new ToolCatalog({ method: "semantic", embedding: { ollama: "nomic-embed-text" } });
await catalog.register(tools); // embeds the batch here
const hits = await catalog.searchAsync("deploy the service", 5);register() returns a promise for every method (BM25 too); search() stays synchronous for BM25 only, and searchAsync() covers all three. To change the endpoint's model or vector dimension, construct a new catalog and re-register.
A SkillCatalog also takes a whole reloaded catalog at once with replaceAll(), for a source that fetches the full set rather than individual changes (ADR 0015). The batch is the catalog: ids missing from it are removed, including ones registered in-process, so a host that mixes local and remote skills composes the batch itself. It mutates in place, so r.skills, every adapted view, and the capability tools already handed to the model all see the reload without being rebuilt.
const outcome = await r.skills.replaceAll([...localSkills, ...(await fetchRemoteSkills())]);
console.log(`reload: +${outcome.added} -${outcome.removed} ~${outcome.updated}`);The corpus swap is the synchronous half of that call, so the counts are already final when it returns — read them without awaiting and a reload whose embedding pass fails still reports what it changed:
const reload = r.skills.replaceAll(batch); // corpus is live; counts are final
try {
await reload; // drives the embedding pass
} catch {
console.warn(`applied +${reload.added} -${reload.removed}, embeddings pending`);
}Only new and re-worded skills are embedded — reloading an unchanged catalog costs no embedding calls — and a reload that races an in-flight operation — dense work, but also an ordinary BM25 searchAsync holding the read lock — throws rather than applying half of itself.
Build-time embedding artifacts (ADR 0018, experimental) avoid corpus/document embedding inference for covered entries on cold start: experimentalBuildEmbeddingArtifact writes a mixed Tool+Skill RAT1 (halves merged internally; no public merge API), and catalogs accept experimentalEmbeddingArtifact: { path } | { bytes } (default onMiss: "error") to warm the dense cache on register / replaceAll — each call re-resolves the artifact source and re-warms the whole current corpus. ToolRegistry / SkillRegistry also expose experimentalBuildEmbeddingArtifact and experimentalWarmEmbeddingsFromArtifact. With default onMiss: "error", every id in each non-empty registering corpus must be covered; a tool-only artifact is valid while Skill stays empty (and vice versa); when both sides register, use a mixed artifact or onMiss: "embed". Semantic/hybrid search still requires query embedding through the configured backend; Local/HF paths may still initialize/load the model, and endpoint performs its normal remote query embedding. ArtifactWarmError covers warm failures (.code, .missing where applicable); ArtifactError covers non-embedder artifact construction failures (embedding/backend failures remain EmbedderError); IncompatibleMergeError extends ArtifactError and may surface from the public mixed builder's internal Tool+Skill merge.
Embedding failures from register()/searchAsync() are typed EmbedderErrors (with a stable .code such as "Load", "NotCached", or "DimensionMismatch"); a dimension mismatch is a DimensionMismatchError subclass — the parity of Python's EmbedderError/DimensionMismatchError. Invalid embedding config still throws at construction.
import { EmbedderError, DimensionMismatchError } from "@ratel-ai/sdk";
try {
await catalog.register(tools);
} catch (err) {
if (err instanceof DimensionMismatchError) {
// the model changed under the corpus — rebuild with a fresh catalog
} else if (err instanceof EmbedderError) {
console.error(`embedding failed (${err.code}): ${err.message}`);
}
}Install
pnpm add @ratel-ai/sdkQuickstart
Save as quickstart.mjs, then run node quickstart.mjs:
import { ToolCatalog } from "@ratel-ai/sdk";
const catalog = new ToolCatalog();
await catalog.register({
id: "get_weather",
name: "get_weather",
description: "Get the current weather for a city.",
inputSchema: {
type: "object",
properties: { city: { type: "string" } },
required: ["city"],
},
outputSchema: {
type: "object",
properties: { forecast: { type: "string" } },
},
execute: ({ city }) => ({ forecast: `Sunny in ${city}` }),
});
const [hit] = catalog.search("What is the weather in Rome?", 1);
console.log(await catalog.invoke(hit.toolId, { city: "Rome" }));Framework adapters
To work in a host framework's native tool and message shapes, adapt the core with a
RatelAdapter from a framework package instead of wiring the capability tools by hand:
import { ratel } from "@ratel-ai/sdk";
import { aiSdk } from "@ratel-ai/vercel-ai-sdk"; // ships separately
const r = ratel({ recallTopK: 5 }).adaptTo(aiSdk());
await r.tools.register(myTools); // async; callable any time, also after modelTools()
const tools = r.modelTools(); // stable capability set — take once, reuse
const messages = await r.appendRecall(history); // per-turn recall (AI SDK idiom)r.tools is a handle over the core's one shared catalog — registration and exposure are separate
acts, and tools registered after modelTools() are still discoverable because the capability tools
search the live catalog. register(...) is async: it validates synchronously (a bad tool throws at
the call site) and its promise resolves once the tools are indexed and, on a semantic/hybrid core,
embedded — await it so embedding errors surface at registration. The core also works standalone,
without any adapter: ratel().tools.register(...) takes native ExecutableTools, modelTools()
returns the three capability tools in native shape, and recall(query) is a pure query returning
the canonical search_capabilities result.
ratel(config) owns one ToolCatalog + SkillCatalog + recall-id counter and every
framework-independent guard (reserved capability-tool ids, top-K clamp, first-registration-wins
on the adapted path, passthrough of provider-run tools); an adapter has three required codecs
(ingest / expose / recallMessages), an optional experimentalExposePassthrough hook, plus
its framework idioms. The experimental hook receives a core-owned invocation wrapper, so a
framework can preserve native tool semantics while client-side passthrough execution still enters
the standard OTel/local trace funnel. adaptTo infers the framework's tool and message types, so
app code needs no casts. A framework tool registered on the un-adapted core throws an error
pointing at the adapter package to install. See ADR-0013.
Continue with the TypeScript guide, capability tools, API reference, or the Vercel AI SDK example.
Facts (experimental)
Tools and skills are pulled — a query ranks them and only the winners reach the model. Facts are the opposite: constant content the agent should always work from (a shop's address, hours, a brand's voice), pushed into the context and deduplicated so it is injected once rather than every turn.
Facts live behind the experimental namespace and may change without a major-version bump. Registering one is like a skill, plus a pin tier:
import { ratel, experimental } from "@ratel-ai/sdk";
const r = ratel();
await r.facts.register([
{
id: "shop-address",
name: "shop address & hours",
description: "where the shop is and when it's open",
body: "Fade & Blade — 12 Baker Street, London. Open Mon–Sat 9am–7pm.",
pin: experimental.Pin.Always, // every turn, regardless of the query
},
{
id: "cancellation",
name: "cancellation policy",
description: "cancelling or rescheduling a booking, and refunds",
body: "Cancel at least 24h ahead for a full refund; same-day is a 50% fee.",
pin: experimental.Pin.Retrieved, // only when the turn's query ranks it in (default)
},
]);Then pick one of two injection modes per turn — the same persist-vs-per-call split as appendRecall vs prepareStep for recall.
ground() — persist into your stored history. Returns only the facts not already present; render each body verbatim and keep it in the messages you save. It takes per-message text, so flatten your history first — the AI SDK's ModelMessage.content is string | Array<Part>, and handing it an array of parts makes the presence check element equality, which never matches (every fact would then re-inject every turn):
const textOf = (m: ModelMessage): string =>
typeof m.content === "string"
? m.content
: m.content.map((p) => ("text" in p ? p.text : "")).join("\n");
const { inject } = await r.ground(userText, messages.map(textOf));
for (const f of inject) {
messages.push({ role: "system", content: f.body }); // verbatim — presence is the dedupe
}Turn 1 injects the address; turn 2 sees it in the transcript and injects nothing. It re-injects only when the body is gone (compaction) or was edited — f.reason is never / evicted / mutated.
groundSnapshot() — per call, nothing stored. Returns the full applicable set every time; put it in the request you're about to send and discard it:
const facts = await r.groundSnapshot(userText);
const result = await generateText({
model,
messages: [...facts.map((f) => ({ role: "system", content: f.body })), ...messages],
});Use ground() for a long-lived agent whose messages you persist (pays once, stays in the cached prefix); groundSnapshot() for one-shot or stateless calls, or to keep injected content out of your stored history.
Facts are host-driven, on their own path: ground()/groundSnapshot() are the only ways facts reach the context. modelTools(), the model-facing search_capabilities tool, and recall() are all unchanged and never return facts — the model doesn't discover facts by calling a tool, and there is no second place to look. You decide what is true and inject it. Every decision is traced (fact_inject with its reason, fact_inject_skip, fact_snapshot), so the skip rate — the tokens you saved — is measurable. See ADR-0017.
Retrieval experiments (experimental)
experimentalDefineExperiment wraps host-supplied async selectors with deterministic A/B
assignment and bounded shadow evaluation. It is opt-in and TypeScript-only. It does not change
ToolCatalog or SkillCatalog ranking, and there is deliberately no stable defineExperiment
alias yet.
This complete example assigns a stable unit, serves one arm, runs the other as detached shadow work, compares their rankings, attributes a later invocation, and reports an outcome:
import { experimentalDefineExperiment } from "@ratel-ai/sdk";
type SearchParams = { query: string };
type SearchHit = { id: string; score: number };
type SearchArm = "control" | "candidate";
const experiment = experimentalDefineExperiment<SearchParams, SearchHit[], SearchArm>({
id: "retrieval-v2",
arms: {
control: {
select: async () => [
{ id: "inspect-ci", score: 1 },
{ id: "search-logs", score: 0.8 },
],
},
candidate: {
select: async () => [
{ id: "search-logs", score: 0.9 },
{ id: "inspect-ci", score: 0.7 },
],
},
},
split: [
{ arm: "control", weight: 90 },
{ arm: "candidate", weight: 10 },
],
ranking: (hits) => hits.map(({ id, score }) => ({ id, score })),
evaluation: {
k: 2,
references: [
"peer-selection",
{ kind: "invocation", window: { turns: 5, maxAgeMs: 300_000 } },
],
outcome: true,
},
fallbackArm: "control",
shadowPolicy: { concurrency: 1 },
});
const selection = await experiment.select(
{ query: "build failure" },
{ unitId: "account-42", shadow: true, k: 2 },
);
const [chosen] = selection.result;
if (chosen !== undefined) {
experiment.reportInvocation({ unitId: "account-42", toolId: chosen.id });
}
experiment.reportOutcome({
selectionId: selection.selectionId,
label: "accepted",
score: 1,
});
console.log(selection.assignedArm, selection.effectiveArm, selection.result);
await experiment.drain();The public instance has five operations:
| operation | contract |
| --- | --- |
| select(params, options) | Returns the transformed effective result plus selectionId, assignedArm, effectiveArm, and arm-callback durationMs. An explicit arm overrides split. |
| warm() | Starts unresolved arm warmups concurrently. It never rejects; failed warmups remain cold and retry on the next call. Selection never waits for warmup. |
| drain() | Waits for a snapshot of detached shadow and comparison work, always all-settled. It is repeatable, does not close the experiment, and does not include work started after the call. |
| reportInvocation({ unitId, toolId, turn? }) | Attributes a tool to the configured in-process invocation window. It is a no-op unless an invocation reference is configured. |
| reportOutcome({ selectionId, label?, score? }) | Appends a delayed outcome when evaluation.outcome is true. At least one non-empty label or finite score is required; repeated reports remain distinct. |
Assignment hashes JSON.stringify([experimentId, unitId]) with SHA-256 into the ordered integer
weights. Pass an opaque stable unit id; telemetry separately records the first 16 lowercase hex
characters of SHA-256(unitId). shadow: true attempts every non-assigned arm in declaration
order. Capacity is per experiment instance and skips instead of queueing. Successful selections
do not await detached shadows; after an assigned selector rejects, fallback may reuse and await an
already-running fallback-arm shadow. Fallback does not run for an empty ranking or a projection
failure.
transform runs synchronously after the arm callback and before ranking, result/comparison
telemetry, and return. Arm callbacks own their deadlines and can choose one from context.role; a
rejection whose name is TimeoutError is classified as a timeout. Rank comparison uses ids
(top-1, exact order, overlap, and Jaccard@K), never cross-arm score deltas. Request k overrides
the configured value, whose default is 10, without truncating the returned result. See
ADR 0019 for every validation and edge-case
rule.
Experiment telemetry needs an OpenTelemetry ContextManager even when no exporter is enabled,
plus both span and Logs processors when exporting. Start descendant model/tool work inside the
arm callback so it inherits the five-field experiment baggage join. See the
retrieval-experiment telemetry scenario.
Migrate a KP-5 vendored stub
Use this mechanical cutover for a host carrying the KP-5 reference implementation:
Upgrade to the first published
@ratel-ai/sdkversion that containsexperimentalDefineExperiment, then update the lockfile.Replace the vendored import and public names:
-import { defineExperiment, type ArmOutcome, type ArmRole, type RankedItem } from "./ratel-ai-sdk.js"; +import { + experimentalDefineExperiment, + type Experiment, + type ExperimentArmOutcome, + type ExperimentArmRole, + type ExperimentRankedItem, +} from "@ratel-ai/sdk";Add the declared arm union as the third generic, for example
Experiment<Params, Result, "legacy" | "ratel">andexperimentalDefineExperiment<Params, Result, "legacy" | "ratel">(...). Omitting all explicit generics and relying on inference is also valid.Rename only the define-time capacity config:
shadow: { concurrency: 1 }becomesshadowPolicy: { concurrency: 1 }. Keep the per-callselect({ shadow })boolean.Keep
select({ k }),evaluation.attributes,fallbackArm,transform, and non-reserved caller attributes. Those KP-5 amendments are part of the package contract.Delete the vendored implementation and its private helpers. Do not replace
withArmContext,hashUnitId,compareRankings, ordrainExperimentForTestsimports; they are intentionally not public. Pass the raw unit id and useexperiment.drain()in shutdown and tests.Add a Logs provider/processor beside the existing span processor. Keep saved filters on
otel.scope.name = "@ratel-ai/sdk", but move experiment event queries from SpanEvents to Logs EventRecords and rename the oldratel.search.resultsexperiment event toratel.experiment.results.Search for stale stub references, then run the host's typecheck, lint, tests, and an in-memory trace-plus-log integration:
rg -n 'ratel-ai-sdk|\bdefineExperiment\b|\bArmOutcome\b|\bArmRole\b|\bRankedItem\b|drainExperimentForTests|withArmContext|hashUnitId|shadow:\s*\{' srcRemaining
shadow:keys should be per-call booleans. Verify one served-plus-shadow request, fallback, capacity skip, active host-parent correlation, and shutdown drain.
Account for these contract differences when updating tests and dashboards:
| contract | KP-5 vendored stub | published SDK contract |
| --- | --- | --- |
| Factory and types | defineExperiment, ArmRole, ArmOutcome, RankedItem | experimentalDefineExperiment, ExperimentArmRole, ExperimentArmOutcome, ExperimentRankedItem; no stable alias |
| Assignment and return | Explicit arm only; result/effective arm/duration | Optional ordered integer split; return also includes UUID selectionId and original assignedArm |
| Lifecycle | Private drain/test helper | Public repeatable warm() and snapshot/all-settled drain() |
| Invocation and outcome | Invocation could emit without a reference; outcome unsupported | Invocation is a no-op until configured; evaluation.outcome: true enables append-only reportOutcome() |
| Capacity and fallback | Shadow capacity covered the full detached continuation; fallback broadly dropped peer comparison | Capacity releases when the arm callback settles; other shadows compare against the effective fallback |
| Correlation stamp | Experiment id, arm, role, and unit | Adds the exact selection_id; direct arm attributes survive without context, descendant baggage does not |
| Event carrier | Experiment SpanEvents and an invocation span | Seven Logs EventRecords, never SpanEvents; requires a log-record processor |
| Results and agreement | ratel.search.results; dynamic agreement.attr.<key> fields | ratel.experiment.results; structured agreement.item_attrs and agreement.result_attrs plus served/shadow facts |
| Content boundary | Stub-specific | Ranked ids/scores are always measurements; item attrs are capture-gated; result values are not emitted, only agreement booleans |
Adapter conformance testkit
Building a RatelAdapter for another framework? @ratel-ai/sdk/testkit ships a runner-agnostic
battery that pins the SPI contract — ingest/expose round-trip, the reserved-id guard, recall
top-K clamping, passthrough semantics, and recall-pair shape. Teach it your framework's tool and
message shapes once, then run the whole battery under your test runner:
import { describe, it } from "vitest";
import { describeAdapterConformance } from "@ratel-ai/sdk/testkit";
import { myConformanceOptions } from "./conformance-options.js";
describeAdapterConformance(myConformanceOptions(), { describe, it });Assertions use node:assert, so no test runner leaks into your published types;
referenceConformanceOptions is a worked example to copy. Prefer full control? adapterConformanceCases(options) returns the named cases to run yourself.
Telemetry
Telemetry is emit-only and always on: the SDK writes ratel.* / gen_ai.* spans and EventRecords to whatever OpenTelemetry providers are registered globally, and registers none itself — with no provider wired, every span is a no-op, so there is nothing to configure or switch off. Delivery is yours:
import { NodeSDK } from "@opentelemetry/sdk-node";
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
import { BatchLogRecordProcessor } from "@opentelemetry/sdk-logs";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto";
new NodeSDK({
spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter({ url: "https://<your-backend>/v1/traces" }))],
logRecordProcessors: [new BatchLogRecordProcessor({ exporter: new OTLPLogExporter({ url: "https://<your-backend>/v1/logs" }) })],
}).start();Both lists matter: with spanProcessors alone, NodeSDK builds the logger provider from the environment and the EventRecords land on the default OTLP endpoint, not the URL above. Several processors can sit side by side, and flush/shutdown stay with the host that owns the provider.
A vendor processor may drop most of it, silently. Emission and delivery are separate: the provider hands every span to every processor, and each destination then applies its own filter. A stock new LangfuseSpanProcessor() keeps a span only if it carries a gen_ai.* attribute or comes from a scope it already knows, and @ratel-ai/sdk is on neither list — so execute_tool <tool> survives while ratel.search, ratel.skill.load, ratel.upstream.register, ratel.auth.flow, and ratel.experiment.arm do not. Nothing errors; the retrieval spans are simply absent. Widening it is one line of the vendor's own config, keyed on scope — see src/telemetry/ for the predicate, the full span inventory, and the ai@7 and Mastra wirings.
Message and tool content is off by default; opt in with the OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT env var or setContentCapture() (see the telemetry guide for the capture modes and their privacy implications). The ratel.* constants themselves live in @ratel-ai/telemetry; this package re-exports only the content-capture gate.
Package layout
src/ is the TypeScript surface (including embedding-artifact.ts for build/warm helpers), native/ contains the NAPI binding, npm/ holds platform packages, and tests live beside their source. From the repository root, run pnpm --filter @ratel-ai/sdk... build and pnpm --filter @ratel-ai/sdk test.
