KB LabsDocs

Writing Your First Adapter

Last updated May 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

Bash
mkdir -p packages/memcache-adapter/src
cd packages/memcache-adapter
pnpm init

Edit package.json:

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:

TypeScript
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.
  • createAdapter is the factory function the platform calls to instantiate the adapter. Takes config and deps, returns an instance.
  • Every method in ICache is implemented, even ones we don't really need (sorted sets). The interface is strict — you can't partial-implement.
  • get returns null (not undefined) 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:

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:

TypeScript
import { defineConfig } from 'tsup';
 
export default defineConfig({
  entry: ['src/index.ts'],
  format: ['esm'],
  dts: true,
  clean: true,
});

Step 4 — Build

Bash
pnpm install
pnpm build

Check 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:

JSON
{
  "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:

Bash
pnpm link /absolute/path/to/packages/memcache-adapter

Or, 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:

Bash
kb-dev restart

Check the REST API logs for adapter init:

Bash
kb-dev logs rest | grep -i cache

You 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:

TypeScript
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:

Bash
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:

  1. Pick an interface from @kb-labs/core-platformICache, ILLM, IStorage, ILogger, IVectorStore, IEmbeddings, IAnalytics, IEventBus.
  2. Implement every method. Partial implementations are rejected by the type checker.
  3. Export manifest with metadata about your adapter.
  4. Export createAdapter as the factory function.
  5. Build the package to dist/.
  6. Register in kb.config.json under platform.adapters[<token>].
  7. Pass options via platform.adapterOptions[<token>].
  8. 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:

TypeScript
// 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:

FieldTypeWhat it gives you
ctx.pluginIdstringWhich plugin is making the call
ctx.permissionsPermissionSpecResolved permissions for this plugin
ctx.lifecycleAdapterLifecycleHooks 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 → governance

Open slots (available to middleware):

SlotWhen to use
rawBefore routing — rarely needed. Logging, request ID stamping.
post-routerAfter backend selection, before rate limiting. Request signing, header injection.
post-resource-brokerAfter 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:

TypeScript
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:

TypeScript
{ 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:

TypeScript
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

ScenarioUse
Replace the entire backing storeFull adapter (createAdapter)
Intercept calls to the existing adapterMiddleware (middlewares[])
Observability, cost tracking, circuit breakingMiddleware
Change how data is stored or fetchedFull adapter
Per-plugin state that resets on unloadMiddleware (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 Map for 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.
Writing Your First Adapter — KB Labs Docs