Writing Your First Adapter
Обновлено 16 мая 2026 г.
Build a custom ICache adapter, register it, and use it from a plugin.
Adapters are the platform's backend layer — every service the runtime depends on (LLM, cache, vector store, storage, logger) is an adapter implementing a TypeScript interface. Writing a custom one means picking an interface, implementing it, packaging it, and registering it in kb.config.json.
This guide builds a minimal cache adapter with an in-memory backend. The same flow applies to any interface. For the complete walkthrough with all the options, see Adapters → Writing a Custom Adapter. This page is the guided version.
Prerequisites
- A running KB Labs workspace.
- Familiarity with TypeScript and
pnpm. - 20 minutes.
What we're building
A simple ICache adapter called @example/adapters-memcache — a plain in-memory KV store with TTL support. Real production caches would wrap Redis or Memcached; this one is deliberately minimal so the focus stays on the adapter structure.
Step 1 — Create the package
mkdir -p packages/memcache-adapter/src
cd packages/memcache-adapter
pnpm initEdit package.json:
{
"name": "@example/adapters-memcache",
"version": "0.1.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": ["dist"],
"scripts": {
"build": "tsup",
"type-check": "tsc --noEmit"
},
"peerDependencies": {
"@kb-labs/core-platform": "^1.0.0"
},
"devDependencies": {
"@kb-labs/core-platform": "^1.0.0",
"tsup": "^8.0.0",
"typescript": "^5.5.0"
}
}Note the dependency model: @kb-labs/core-platform is a peer dependency (the host runtime provides it at runtime) plus a dev dependency (you need the types at build time).
Step 2 — Write the adapter
src/index.ts:
import type {
AdapterManifest,
AdapterFactory,
ICache,
} from '@kb-labs/core-platform';
// ─── Adapter manifest ──────────────────────────────────────────────────
export const manifest: AdapterManifest = {
manifestVersion: '1.0.0',
id: 'memcache',
name: 'In-Memory Cache',
version: '0.1.0',
type: 'core',
implements: 'ICache',
capabilities: { batch: false, streaming: false },
description: 'A simple in-memory ICache implementation',
};
// ─── Config schema ──────────────────────────────────────────────────────
interface MemcacheConfig {
maxEntries?: number; // default 10000
}
// ─── Implementation ─────────────────────────────────────────────────────
interface Entry<T> {
value: T;
expiresAt: number | null;
}
class MemCache implements ICache {
private store = new Map<string, Entry<unknown>>();
private sortedSets = new Map<string, Map<string, number>>();
constructor(private config: MemcacheConfig) {}
private isExpired(entry: Entry<unknown>): boolean {
return entry.expiresAt !== null && entry.expiresAt < Date.now();
}
async get<T>(key: string): Promise<T | null> {
const entry = this.store.get(key);
if (!entry || this.isExpired(entry)) {
this.store.delete(key);
return null;
}
return entry.value as T;
}
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
if (this.store.size >= (this.config.maxEntries ?? 10000)) {
const oldest = this.store.keys().next().value;
if (oldest) this.store.delete(oldest);
}
this.store.set(key, {
value,
expiresAt: ttl ? Date.now() + ttl : null,
});
}
async delete(key: string): Promise<void> {
this.store.delete(key);
}
async clear(pattern?: string): Promise<void> {
if (!pattern) {
this.store.clear();
this.sortedSets.clear();
return;
}
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
for (const key of this.store.keys()) {
if (regex.test(key)) this.store.delete(key);
}
}
async zadd(key: string, score: number, member: string): Promise<void> {
let set = this.sortedSets.get(key);
if (!set) {
set = new Map();
this.sortedSets.set(key, set);
}
set.set(member, score);
}
async zrangebyscore(key: string, min: number, max: number): Promise<string[]> {
const set = this.sortedSets.get(key);
if (!set) return [];
return [...set.entries()]
.filter(([, score]) => score >= min && score <= max)
.sort(([, a], [, b]) => a - b)
.map(([member]) => member);
}
async zrem(key: string, member: string): Promise<void> {
this.sortedSets.get(key)?.delete(member);
}
async setIfNotExists<T>(key: string, value: T, ttl?: number): Promise<boolean> {
if (this.store.has(key)) {
const entry = this.store.get(key)!;
if (!this.isExpired(entry)) return false;
}
await this.set(key, value, ttl);
return true;
}
}
// ─── Factory ────────────────────────────────────────────────────────────
export const createAdapter: AdapterFactory<MemcacheConfig, Record<string, never>, ICache> = (config) => {
return new MemCache(config ?? {});
};A few things to notice:
- The manifest is a plain object export. The platform's adapter loader reads it to know the adapter's metadata.
createAdapteris the factory function the platform calls to instantiate the adapter. Takes config and deps, returns an instance.- Every method in
ICacheis implemented, even ones we don't really need (sorted sets). The interface is strict — you can't partial-implement. getreturnsnull(notundefined) when a key is missing. Matches Redis semantics.clear(pattern?)handles both the clear-all case and glob-pattern filtering.
See Adapters → ICache for the interface reference.
Step 3 — Add tsconfig and tsup
tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}tsup.config.ts:
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
clean: true,
});Step 4 — Build
pnpm install
pnpm buildCheck that dist/index.js and dist/index.d.ts exist.
Step 5 — Register the adapter
Edit your workspace's .kb/kb.config.json and swap the cache entry:
{
"platform": {
"adapters": {
"llm": "@kb-labs/adapters-openai",
"cache": "@example/adapters-memcache",
"storage": "@kb-labs/adapters-fs"
},
"adapterOptions": {
"cache": {
"maxEntries": 5000
}
}
}
}The adapter package name in platform.adapters.cache must match your published package name. For local development, link the package into your workspace:
pnpm link /absolute/path/to/packages/memcache-adapterOr, if you're using pnpm workspaces, add the package to the workspace and reference it via workspace:*.
Step 6 — Restart services
Adapters load at service startup:
kb-dev restartCheck the REST API logs for adapter init:
kb-dev logs rest | grep -i cacheYou should see something like:
[rest] Loaded adapter: cache (memcache, v0.1.0)Step 7 — Test it from a plugin
Write a tiny plugin handler that uses the cache:
import { defineCommand, useCache, type CLIInput } from '@kb-labs/sdk';
export default defineCommand({
id: 'test:cache',
handler: {
async execute(ctx, input) {
const cache = useCache();
if (!cache) {
return { exitCode: 1, error: { message: 'Cache not configured' } };
}
await cache.set('greeting', { value: 'hello', when: Date.now() }, 60_000);
const hit = await cache.get<{ value: string; when: number }>('greeting');
return { exitCode: 0, result: hit };
},
},
});Run it:
pnpm kb test:cache
# → { value: 'hello', when: 1729500000000 }Your custom adapter is now the backing store for useCache() across the entire platform — every plugin that calls useCache() sees your implementation.
What just happened
The same flow works for every adapter interface:
- Pick an interface from
@kb-labs/core-platform—ICache,ILLM,IStorage,ILogger,IVectorStore,IEmbeddings,IAnalytics,IEventBus. - Implement every method. Partial implementations are rejected by the type checker.
- Export
manifestwith metadata about your adapter. - Export
createAdapteras the factory function. - Build the package to
dist/. - Register in
kb.config.jsonunderplatform.adapters[<token>]. - Pass options via
platform.adapterOptions[<token>]. - Restart services to load the new adapter.
Declaring adapter middleware (optional)
Sometimes you don't need to replace an adapter — you just want to intercept calls to an existing one. For example: track LLM costs on top of whatever provider is configured, or add a circuit breaker to storage calls.
Adapters can declare middleware in AdapterManifest.middlewares. Each middleware is a function that receives the current adapter and a context object, and returns a new adapter — the classic wrap pattern:
// src/middlewares/cost-tracker.ts
import type { AdapterMiddlewareFn } from '@kb-labs/plugin-runtime/platform';
import type { ILLM } from '@kb-labs/core-platform';
export const middleware: AdapterMiddlewareFn<ILLM> = (adapter, ctx) => ({
...adapter,
complete: async (prompt, options) => {
const start = Date.now();
const result = await adapter.complete(prompt, options);
recordCost(Date.now() - start, ctx.pluginId);
return result;
},
});ctx carries three things:
| Field | Type | What it gives you |
|---|---|---|
ctx.pluginId | string | Which plugin is making the call |
ctx.permissions | PermissionSpec | Resolved permissions for this plugin |
ctx.lifecycle | AdapterLifecycle | Hooks to run code on ready/dispose/events |
Named slots
Every adapter middleware targets a named slot in the pipeline:
raw → router → post-router → resource-broker → post-resource-broker → governanceOpen slots (available to middleware):
| Slot | When to use |
|---|---|
raw | Before routing — rarely needed. Logging, request ID stamping. |
post-router | After backend selection, before rate limiting. Request signing, header injection. |
post-resource-broker | After rate limiting, before permissions. Default for most middleware. Cost tracking, circuit breaker, fallback logic. |
Reserved slots (router, resource-broker, governance) are system-only. Targeting one in your manifest is a validation error.
Declare the slot and set priority for ordering within it:
export const manifest: AdapterManifest = {
// ...existing fields...
middlewares: [
{
id: 'cost-tracker',
handler: './middlewares/cost-tracker.js',
slot: 'post-resource-broker', // named slot — recommended
target: 'llm',
priority: 10,
},
],
};Priority
priority is local to the slot — it orders middleware within the same slot, not across all stages. Default is 0.
post-resource-broker slot (applied in this order):
priority 0 → circuit-breaker (innermost — closest to underlying adapter)
priority 10 → cost-tracker
priority 20 → request-logger (outermost — first to intercept incoming calls)Lower priority number = applied first = innermost wrapper = intercepts calls last. Higher number = outermost = intercepts calls first. When in doubt: put the middleware that should run closest to the real adapter at priority 0.
Because priority is local, adding a new system stage never shifts your numbers. Two middleware from different adapters in the same slot are sorted by priority; ties resolve by load order.
If you need fine-grained placement relative to a specific stage rather than a slot, use after or before:
{ id: 'my-mw', handler: '...', after: 'resource-broker', target: 'llm' }Use after/before only when you genuinely need relative ordering to a specific stage. For everything else, slot is simpler and more resilient to future pipeline changes.
Lifecycle hooks
The middleware function runs once per plugin — it wraps the adapter when the plugin is set up. Use ctx.lifecycle for setup and teardown:
export const middleware: AdapterMiddlewareFn<ILLM> = (adapter, ctx) => {
// Stateful middleware — one instance per plugin
const stats = { calls: 0, totalMs: 0 };
ctx.lifecycle.onReady(() => {
console.log(`[${ctx.pluginId}] cost tracker ready`);
});
ctx.lifecycle.onDispose(async () => {
await flushStats(ctx.pluginId, stats);
});
// Subscribe to a platform event
ctx.lifecycle.on('resource-broker:quota-reset:llm', () => {
stats.calls = 0;
});
return {
...adapter,
complete: async (prompt, options) => {
stats.calls++;
const start = Date.now();
const result = await adapter.complete(prompt, options);
stats.totalMs += Date.now() - start;
return result;
},
};
};onReady(fn)— called after the full pipeline is assembled, before the first call arrives.onDispose(fn)— called when the plugin is unloaded or the context is torn down. Use it to flush buffers, close connections, clear timers.on(event, fn)— subscribe to a platform lifecycle event. Currently documented event:resource-broker:quota-reset:<adapter>(fires when rate-limit quota resets for an adapter).
When to use middleware vs a full adapter
| Scenario | Use |
|---|---|
| Replace the entire backing store | Full adapter (createAdapter) |
| Intercept calls to the existing adapter | Middleware (middlewares[]) |
| Observability, cost tracking, circuit breaking | Middleware |
| Change how data is stored or fetched | Full adapter |
| Per-plugin state that resets on unload | Middleware (onDispose) |
See Concepts → Adapter System → Middleware pipeline for the full picture.
What's next
- Persist the data. Real caches don't lose state on restart. Swap the
Mapfor Redis, SQLite, or any other backend. - Add metrics. Count hits, misses, evictions. Expose them via a
/metrics-like endpoint. - Handle errors gracefully. Network failures, connection pooling, reconnection logic.
- Publish to npm. Once it's production-ready, publish the package and let other deployments use it.
- Write tests. Use Vitest directly — the adapter is a plain class, no special test harness needed.
What to read next
- Adapters → Writing a Custom Adapter — the complete walkthrough with all options and edge cases.
- Adapters → Overview — conceptual picture of the adapter layer.
- Adapters → ICache — full interface reference.
- Adapters → Service Interfaces — every interface you can implement.
- Configuration → kb.config.json — adapter registration and options.