KB LabsDocs

Overview

Last updated April 7, 2026


Interface-first adapters: how they're declared, loaded, wired, and extended.

KB Labs is built interface-first. The runtime depends on a small set of TypeScript interfaces — ILLM, ICache, IStorage, ILogger, and so on — and adapters are the concrete implementations of those interfaces. Every backend the platform talks to (OpenAI, Qdrant, Redis, SQLite, Pino, Docker) is an adapter package. Swapping one for another is a one-line change in kb.config.json.

This page covers how adapters are declared, loaded, and wired together. The per-interface reference pages under Adapters → Service Interfaces document each interface's method surface.

The separation

┌─────────────────────────────────────────────────────┐
│                    Plugin code                      │
│                                                     │
│   const llm   = useLLM();                           │
│   const cache = useCache();                         │
│   const store = useStorage();                       │
└────────────────────┬────────────────────────────────┘

┌─────────────────────────────────────────────────────┐
│              Core-platform interfaces               │
│                                                     │
│   ILLM, ICache, IStorage, ILogger, IVectorStore,    │
│   IEmbeddings, IAnalytics, IEventBus, ISQLDatabase, │
│   IDocumentDatabase, IKeyValueDatabase, ...         │
└────────────────────┬────────────────────────────────┘

┌──────────────────────────────────────────────────────────────┐
│                    Adapters                                  │
│                                                              │
│   @kb-labs/adapters-openai        (implements ILLM)          │
│   @kb-labs/adapters-redis         (implements ICache)        │
│   @kb-labs/adapters-fs            (implements IStorage)      │
│   @kb-labs/adapters-pino          (implements ILogger)       │
│   @kb-labs/adapters-qdrant        (implements IVectorStore)  │
│   @kb-labs/adapters-analytics-sqlite  (implements IAnalytics)│
│   ...                                                        │
└──────────────────────────────────────────────────────────────┘

The interfaces live in @kb-labs/core-platform. The adapters live in infra/kb-labs-adapters/. Plugin code never imports adapter packages directly — it goes through the runtime hooks, which return whatever adapter the user configured.

Declaration: kb.config.json

You choose adapters by listing their packages in platform.adapters:

JSON
{
  "platform": {
    "adapters": {
      "llm":         "@kb-labs/adapters-openai",
      "cache":       "@kb-labs/adapters-redis",
      "storage":     "@kb-labs/adapters-fs",
      "vectorStore": "@kb-labs/adapters-qdrant",
      "logger":      "@kb-labs/adapters-pino",
      "analytics":   "@kb-labs/adapters-analytics-sqlite"
    },
    "adapterOptions": {
      "cache":   { "url": "redis://localhost:6379" },
      "storage": { "basePath": ".kb/storage" }
    }
  }
}

Each key in platform.adapters is a runtime token — the well-known name the runtime binds the adapter to (llm, cache, ...). The value is the npm package path that exports the adapter. The options under platform.adapterOptions[token] are passed verbatim to the adapter's createAdapter(config, deps) factory.

See Configuration → kb.config.json for the full schema and every supported token.

Anatomy of an adapter package

An adapter package exports two things: a manifest and a factory.

TypeScript
// @kb-labs/adapters-pino/src/index.ts
import type { AdapterManifest, AdapterFactory, ILogger, IAnalytics } from '@kb-labs/core-platform';
 
export const manifest: AdapterManifest = {
  manifestVersion: '1.0.0',
  id: 'pino-logger',
  name: 'Pino Logger',
  version: '1.0.0',
  type: 'core',
  implements: 'ILogger',
  optional: { adapters: ['analytics'] },
  capabilities: { streaming: true },
};
 
export const createAdapter: AdapterFactory<PinoConfig, { analytics?: IAnalytics }, ILogger> = (
  config,
  deps,
) => {
  return new PinoAdapter(config, deps);
};

The runtime imports the package, reads the manifest, and calls createAdapter(config, deps) with:

  • config — the value from platform.adapterOptions[token] in kb.config.json.
  • deps — an object populated with any dependencies declared in the manifest.

The return value is the adapter instance. It must implement the interface named in manifest.implements.

The manifest, in detail

Source of truth: adapter-manifest.ts.

Identity

TypeScript
{
  manifestVersion: '1.0.0',   // schema version for future migrations
  id: 'pino-logger',           // unique, kebab-case
  name: 'Pino Logger',         // human-readable
  version: '1.0.0',            // semver
  description?: string,
  author?: string,
  license?: string,            // SPDX identifier
  homepage?: string,
}

Classification

TypeScript
{
  type: 'core' | 'extension' | 'proxy',
  implements: 'ILogger',  // interface name — informational only
}
  • core — primary adapter implementing an interface. Most adapters are core.
  • extension — adapter that extends another via a hook (see below). Think "log ring buffer extending the logger".
  • proxy — adapter that wraps/delegates to another adapter. Used by the IPC layer for cross-process communication.

implements is informational — the runtime doesn't do runtime type checking against it. TypeScript provides compile-time guarantees in the factory's return type.

Dependencies

TypeScript
{
  requires?: {
    adapters?: (string | { id: string; alias?: string })[];
    platform?: string;  // semver range, e.g. '>= 1.0.0'
  };
  optional?: {
    adapters?: string[];
  };
  contexts?: string[];  // 'workspace' | 'analytics' | 'tenant' | 'request'
}

Runtime tokens, not manifest IDs. When log-persistence declares requires.adapters: ['db'], it means "the adapter bound to the db token in config", not "the adapter whose manifest ID is db". This is called out loudly in the source — it's the easy thing to get wrong when writing dependent adapters.

Required dependencies that can't be resolved cause the adapter to fail loading. Optional dependencies resolve to undefined in the deps parameter if missing, and the adapter continues.

Contexts are runtime-injected values the loader threads into the adapter's config:

  • workspace{ cwd: string }
  • analyticsAnalyticsContext with source, actor, and runId
  • tenant / request — reserved for future multi-tenancy and per-request scoping

Extension points

Extensions are how one adapter augments another without tight coupling.

TypeScript
{
  type: 'extension',
  implements: 'ILogRingBuffer',
  extends: {
    adapter: 'logger',   // target runtime token
    hook: 'onLog',       // method on target that accepts a callback
    method: 'append',    // method on this adapter to invoke
    priority: 10,        // higher = called first; default 0
  },
}

After all adapters load, the runtime walks every extension, finds the target adapter, and registers target[hook](event => this[method](event)). Multiple extensions can hook the same target; they fire in priority order, then in registration order.

This is how @kb-labs/adapters-log-ringbuffer and @kb-labs/adapters-log-sqlite both plug into the Pino logger at the same time: both declare extends: { adapter: 'logger', hook: 'onLog', ... } with different methods and priorities. The logger itself knows nothing about them — it just calls onLog(record) for each log entry, and the hook dispatcher delivers.

Capabilities

TypeScript
{
  capabilities?: {
    streaming?: boolean;
    batch?: boolean;
    search?: boolean;
    transactions?: boolean;
    custom?: Record<string, unknown>;
  };
}

Informational flags that the runtime (or plugin code) can read to branch on. For example, an LLM adapter with capabilities.streaming === false will cause useLLM().stream() to fall back to complete() unless the caller explicitly requires streaming.

Config schema

TypeScript
configSchema?: Record<string, {
  type: 'string' | 'number' | 'boolean' | 'object' | 'array';
  description?: string;
  default?: unknown;
  required?: boolean;
  enum?: unknown[];
  properties?: Record<string, unknown>;
}>;

Purely informational — used for documentation generation, IDE completion, config builders. The runtime does not validate adapter config against this schema; adapters validate their own inputs.

Loading order

Adapters are loaded in dependency order using Kahn's topological sort. The process, straight from adapter-loader.ts:

  1. For every entry in platform.adapters, dynamically import the package and pull out its manifest + createAdapter.
  2. Build a dependency graph: every requires.adapters entry becomes an edge from the dependency to the dependent.
  3. Topologically sort the graph. Circular dependencies throw at startup.
  4. For each node in sorted order:
    • Resolve its deps into an object keyed by token (or by the alias if one was declared).
    • Inject any requested contexts into the config.
    • Call createAdapter(config, deps).
    • Register the resulting instance under its runtime token.
  5. After all core adapters are in place, process every extensions entry and wire hooks.

The end result is a global platform singleton with every adapter attached at its well-known token. This is what usePlatform() and the useLLM()/useCache()/etc. hooks read from.

Multi-adapter setups

platform.adapters[token] accepts a single string, an array of strings, or null:

JSON
{
  "platform": {
    "adapters": {
      "llm":       ["@kb-labs/adapters-openai", "@kb-labs/adapters-vibeproxy"],
      "analytics": null
    }
  }
}
  • Single string — the normal case. One adapter per token.
  • Array — multi-provider setups. The first entry is the primary/default, the rest are reachable via routing. For LLM, the router reads adapterOptions.llm.tierMapping to decide which adapter to use per tier.
  • null — explicitly install the NoOp adapter for that token. The hook will still be defined, but every operation is a silent no-op. Useful when you want the runtime to know the service is deliberately disabled.
  • Omitted — service is simply not configured. The corresponding hook returns undefined, and plugins degrade gracefully.

NoOp adapters

Every interface has a matching NoOp implementation under @kb-labs/core-platform/noop. NoOp adapters let the runtime start even when nothing is configured — operations succeed silently and data is discarded.

This is deliberate: it lets you develop plugins without a full infrastructure stack running.

First-party adapter catalog

The monorepo ships these adapters under infra/kb-labs-adapters/:

TokenPackageInterface
llm@kb-labs/adapters-openaiILLM
llm@kb-labs/adapters-vibeproxyILLM
embeddings@kb-labs/adapters-openai/embeddingsIEmbeddings
vectorStore@kb-labs/adapters-qdrantIVectorStore
cache@kb-labs/adapters-redisICache
storage@kb-labs/adapters-fsIStorage
logger@kb-labs/adapters-pinoILogger
logRingBuffer@kb-labs/adapters-log-ringbufferextends logger
logPersistence@kb-labs/adapters-log-sqliteextends logger
analytics@kb-labs/adapters-analytics-sqliteIAnalytics
analytics@kb-labs/adapters-analytics-fileIAnalytics
analytics@kb-labs/adapters-analytics-duckdbIAnalytics
eventBus@kb-labs/adapters-eventbus-cacheIEventBus
db@kb-labs/adapters-sqliteISQLDatabase
db@kb-labs/adapters-postgresISQLDatabase
documentDb@kb-labs/adapters-mongodbIDocumentDatabase
workspace@kb-labs/adapters-workspace-localfsIWorkspaceProvider
workspace@kb-labs/adapters-workspace-worktreeIWorkspaceProvider
workspace@kb-labs/adapters-workspace-agentIWorkspaceProvider
environment@kb-labs/adapters-environment-dockerIEnvironmentProvider
snapshot@kb-labs/adapters-snapshot-localfsISnapshotProvider

Every adapter lives in its own package under infra/kb-labs-adapters/packages/. Each package has a README covering its specific config options.

Writing a custom adapter

See Writing a Custom Adapter for the step-by-step. The short version:

  1. Pick an interface from @kb-labs/core-platform — whichever one you're implementing.
  2. Create an npm package that depends on @kb-labs/core-platform.
  3. Export manifest: AdapterManifest and createAdapter: AdapterFactory.
  4. Implement the interface in the factory return value.
  5. Publish the package (or link it locally with kb marketplace link).
  6. Add it to platform.adapters[token] in kb.config.json.

The platform doesn't care whether the package comes from npm, a local monorepo, or kb marketplace link. As long as it exports the two contract symbols and the factory returns an object that quacks like the interface, it works.

What's NOT in an adapter

Adapters are thin. They translate platform interface calls into vendor-specific API calls, and that's it. Things that don't belong in an adapter:

  • Plugin logic. An LLM adapter calls the OpenAI API; it doesn't know how to write commit messages. Commit-message generation lives in a plugin.
  • Permission enforcement. Permissions are enforced by the runtime sandbox (ctx.runtime.fs, ctx.runtime.env, ...), not by adapters. The storage adapter doesn't know about permissions.platform.storage — the runtime intercepts writes before they reach the adapter.
  • Analytics attribution. Adapters emit raw events; the platform's analytics wrapper attributes them to the current plugin via setSource(). Don't hard-code a product name in your adapter.
  • Cross-cutting concerns. Rate limiting, retries, and cache policy live in the core.resourceBroker layer and in per-adapter wrappers. Adapters should trust they'll be wrapped.
Overview — KB Labs Docs