Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Drivers

Host specifics are drivers behind contracts in @sms/contracts (Linux device-driver style, and dsh’s “consumers depend on definitions, never providers”). Plugins and the baseline depend only on the contract; a downstream app picks a driver per concern.

ConcernContractDrivers
persistenceStore { db: Database, log, blobs, close }store-sqlite · store-postgres · store-do · store-d1 (all built on the store-sql helper)
database for pluginsDatabase { dialect, exec, query, columns, close } (key database)provided by every store
files / objectsObjectStore { put, get, head, delete, list } (key objects)objects-s3 (AWS S3, MinIO, R2 via S3 API) · objects-r2 (bucket binding) · createMemoryObjectStore (tests)
HTTPHost { listen(handler) → HostServer }host-node · host-fastify · host-cloudflare
tenancyTenantResolver (request) → tenantIdtenantFromHeader, tenantFromSubdomain({ root, fallback, reserved }), your own
agent codeExecutor { load(bundle, host, opts?) → ExecutorInstance } (in @sms/kernel)executor-node · executor-quickjs
modelModelService { name, complete(req), stream?(req) }model-claude · model-gpt (subscription CLIs) · model-claude-api · model-gpt-api · model-openrouter (API keys, Worker-safe) · model-gpt-oauth (ChatGPT subscription, Worker-safe, unofficial) · createScriptedModel (tests)

Store drivers

@sms/store-sql turns any Database into a Store (tables kernel_events, kernel_blobs). A driver is therefore just a Database:

export function myDatabase(conn): Database {
  return {
    dialect: 'postgres' | 'sqlite',
    exec: async (sql) => { /* DDL, may be several statements */ },
    query: async (sql, params = []) => rows,  // `?` placeholders; return rows (INSERT … RETURNING id)
    columns: async (table) => [...names],     // [] if the table does not exist
    close: async () => {},
  }
}
export const openMyStore = async (conn) => createSqlStore(myDatabase(conn))

Dialect rules the collections plugin relies on (sqlDialect(dialect) in store-sql): auto-increment id column, BOOLEAN vs INTEGER, DOUBLE PRECISION vs REAL, ?$n (use numberPlaceholders). All identifiers are validated against ^[a-z][a-z0-9_]{0,40}$ and double-quoted.

  • sqlitenode:sqlite, one file per kernel (:memory: for tests), WAL on.
  • postgrespg.Pool from a connectionString (creates the schema, pins search_path per connection) or any { query } client such as PGlite (in-process Postgres, used by the tests). schema = one per tenant.
  • do — a Durable Object’s ctx.storage.sql (structural type, no workers-types dependency).
  • d1 — a Cloudflare D1 binding (openD1Store(env.DB); structural type). D1 is SQLite without schemas and single-writer per database, so tenancy is one D1 database per tenant — pick the binding by tenant id in createCloudflareApp({ store }). Multi-line DDL is flattened because D1Database.exec runs one statement per line. Use it when a tenant’s data must be reachable outside its Durable Object (dashboards, wrangler d1 queries); store-do is otherwise simpler and faster.

Two suites run against every driver — run both against yours: packages/core/contracts/test/database-contract.ts (databaseContract(name, () => db): DDL, ? binding, columns(), quoting) and packages/plugins/collections/test/contract.ts, which boots the collections plugin on top.

Object storage drivers

ObjectStore is the file/attachment counterpart of Database: an S3-shaped key → bytes store the composition root provides under the key objects (OBJECTS_KEY; createBaseline defines it system ↔ system, same wall as database). Keys are /-separated paths (no leading /, no ..); put accepts a string, bytes or a ReadableStream; get returns metadata plus body (stream) / bytes() / text(); list({ prefix, delimiter, limit, cursor }) pages S3-style and groups folders into prefixes when a delimiter is given. No multipart, no presigned URLs.

  • s3createS3ObjectStore({ bucket, endpoint, region, accessKeyId, secretAccessKey, sessionToken, forcePathStyle }): Signature V4 over pure fetch + WebCrypto, no aws-sdk, runs in Node and Workers. One driver for AWS S3 (no endpoint), MinIO (endpoint: 'http://localhost:9000' — path style is on whenever an endpoint is set) and R2 from outside Cloudflare (endpoint: 'https://<account>.r2.cloudflarestorage.com', region: 'auto', an R2 API token). Errors are S3Error { status, code }.
  • r2createR2ObjectStore(env.FILES) on a bucket binding inside a Worker / Durable Object: no credentials, streams pass straight through. Structural R2BucketLike type, no workers-types dependency.

Provide it from your composition root exactly like the database:

{ manifest: { id: 'app.objects', version: '1', provide: [OBJECTS_KEY] },
  activate: (ctx) => ctx.provide(OBJECTS_KEY, createS3ObjectStore({ … })) }

The contract suite packages/core/contracts/test/objects-contract.ts (objectsContract(name, () => store)) runs offline against the memory store and both drivers’ fakes in pnpm test; SMS_LIVE_OBJECTS=1 S3_ENDPOINT=http://localhost:9000 S3_BUCKET=… S3_ACCESS_KEY_ID=… S3_SECRET_ACCESS_KEY=… pnpm test (optional S3_REGION) runs it against a real MinIO / R2 / S3 bucket (a fresh t-…/ prefix per run).

Host drivers

A host takes RequestHandler = (Request) → Promise<Response> and puts it on the wire.

  • nodecreateNodeHost({ port, host }).listen(handle); toRequest/sendResponse are exported for reuse.
  • fastifycreateFastifyHost({ app, prefix }).listen(handle) mounts a catch-all under prefix on your existing Fastify instance (raw bodies, reply.hijack()), or creates one.
  • cloudflarecreateCloudflareApp({ resolve, binding, tenant }) returns the Worker fetch and a Durable Object class; each tenant’s DO opens store-do on its SQLite and calls your tenant({ tenantId, env, store }), which returns anything with a handle (a createBaseline() result). One traditional Worker, no Dynamic Workers. store: ({ tenantId, env, state }) => Store swaps the DO SQLite for e.g. openD1Store(env.DB). The driver depends only on contracts, so it can host any RequestHandler.

Executors

executor-nodeexecutor-quickjs
mechanismworker_threads, code as a data: ESM import, ambient globals strippedQuickJS interpreter compiled to WASM, one runtime per plugin
boundarysteering, not security (same process)real: own heap, memoryLimitBytes, CPU deadline per slice (sliceMs), no I/O, no import
bridgeRPC channel (@sms/plugin-sdk)JSON over one host function; cap built by an in-VM bootstrap
runs onNodeNode, browsers, Workers (variant: import('@jitl/quickjs-singlefile-mjs-release-sync'))
use forlocal dev, fastestproduction

Both implement Executor.load(bundle, host, opts?) → { call, dispose } and both pass the same executor tests (packages/drivers/executor/{node,quickjs}/test/executor.test.ts). Guest limits in QuickJS: no timers, no fetch, no process; functions inside contributed items are marshalled as { $fn } refs; every value crossing is JSON.

Model drivers

ModelService.complete({ system, messages, tools }) → { content, stopReason } is stateless and mirrors the Messages API shape: the loop executes tool_use blocks and calls again with tool_results. A driver may also implement stream(req): AsyncIterable<ModelEvent> — the same turn as text_delta / thinking_delta (reasoning, where the provider exposes it) / tool_use (complete calls) events ending in done: { response }, which must equal what complete() returns. Consumers call streamOf(model, req) from @sms/contracts, which falls back to complete() for drivers without it. model-openrouter and model-gpt-oauth stream today; the CLI-backed drivers fall back.

Six drivers ship. Two run on a locally logged-in CLI — a subscription, no API key — which means a Node process that can spawn claude / codex, so neither works in a Worker:

  • @sms/model-claude — the Claude Agent SDK (claude login). Kernel tools become an in-process MCP server; one SDK session per conversation, resumed when the system prompt changes.
  • @sms/model-gpt — the Codex SDK (codex login). Codex has no in-process tool API, so the driver runs a localhost streamable-HTTP MCP server (bearer token per process) and points each codex exec at it. One codex exec per turn, resumed by thread id; developer_instructions carries the system prompt; read-only sandbox in an empty scratch dir, no network, no web search (Codex’s shell tools cannot be removed, only contained). codex exec forces approval_policy=never and then only auto-approves MCP tools annotated readOnlyHint — the driver marks the kernel’s tools so (they never touch the host; the judge is their gate).

Both are built on SessionBridge from @sms/contracts: an agent SDK runs its own loop and executes tools itself, so the bridge exposes the kernel’s tools to the SDK and makes each tool handler block until the next complete() delivers the matching tool_result, handing the accumulated assistant blocks back to the loop as one turn. A new agent-SDK driver implements SessionBackend (start / setTools / send / close) and calls bridge.onText / toolCall / onTurnEnd / onError from the SDK’s stream. A plain request/response provider (an HTTP model API) needs none of that: map messages and tools in complete() directly, as createScriptedModel does. The two API-key drivers are exactly that, and being pure fetch they run anywhere — Node, Cloudflare Workers, Durable Objects (so does model-openrouter):

  • @sms/model-claude-api@anthropic-ai/sdk, Messages API (createClaudeApiModel({ apiKey, model, effort }); default claude-opus-5, adaptive thinking). Thinking blocks are not ModelContent, so the driver caches those that preceded a tool call and re-attaches them when the loop replays that assistant turn. Pass client to use a Bedrock/Vertex/Foundry client instead.
  • @sms/model-gpt-apiopenai, Responses API (createGptApiModel({ apiKey, model, effort }); default gpt-5.5, store: false with encrypted reasoning replayed the same way).
  • @sms/model-openrouteropenai pointed at https://openrouter.ai/api/v1, Chat Completions (createOpenRouterModel({ apiKey, model, effort, appName, siteUrl }); model is an OpenRouter slug, default anthropic/claude-sonnet-4.5; effort maps to OpenRouter’s unified reasoning.effort, nonemax). OpenRouter’s HTTP-200 errors (choices[0].error / finish_reason: 'error') are thrown. OpenRouter’s reasoning_details are cached and replayed before tool calls the same way. One key, any vendor — the simplest way to try a model none of the other drivers cover.

Subscription vs API key: a personal Claude / ChatGPT subscription is officially usable only through the CLI drivers on a machine you log in on; anything serverless or multi-user needs the -api drivers and a key.

  • @sms/model-gpt-oauth — the exception, for personal single-user instances: the OAuth tokens codex login writes to ~/.codex/auth.json sent to Codex’s own backend (chatgpt.com/backend-api/codex, Responses-shaped, SSE) with Codex CLI’s headers — the same wiring OpenClaw, Hermes, pi and opencode use. Pure fetch, so it runs in a Worker. createGptOauthModel({ unofficial: true, tokens, store?, model, effort }); tokens is a CodexTokens, or the text of auth.json (in Node: readCodexAuth() from @sms/model-gpt-oauth/node); store (kvTokenStore(kv), or fileTokenStore(path) from /node) persists refreshed tokens. Tokens are refreshed before expiry and once more on a 401. unofficial: true is mandatory because the endpoint is undocumented, fingerprints clients and may change, and OpenAI’s docs say programmatic use should go through API keys and that tokens must not be pooled or shared — never put it behind a multi-user service. SMS_LIVE_MODELS=1 runs its contract on your login.

Any driver must pass packages/core/contracts/test/model-contract.ts (streaming drivers get an extra case that checks the deltas add up to done.response); the -api / -oauth drivers run it offline against a scripted fetch, and live behind SMS_LIVE_MODELS=1 (CLI and oauth drivers: the login; -api drivers: the key in ANTHROPIC_API_KEY / OPENAI_API_KEY / OPENROUTER_API_KEY).

Writing a new driver

  1. Implement the contract in a new package packages/drivers/<kind>/<name> depending only on @sms/contracts (and @sms/kernel for Executor). Never import a sibling driver — the gate (scripts/quality/layers.ts) allows only the store-sql helper and the listed exceptions (host-fastify → host-node, host-cloudflare → store-do).
  2. Reuse the shared tests: store drivers run databaseContract and collectionsContractTests; object stores run objectsContract; executors copy packages/drivers/executor/quickjs/test/executor.test.ts; model drivers run modelContract from packages/core/contracts/test.
  3. Add it to tsconfig.build.json references and keep package.json in the standard shape (exports with sms-source / types / import, files: ["dist"]).