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.
| Concern | Contract | Drivers |
|---|---|---|
| persistence | Store { db: Database, log, blobs, close } | store-sqlite · store-postgres · store-do · store-d1 (all built on the store-sql helper) |
| database for plugins | Database { dialect, exec, query, columns, close } (key database) | provided by every store |
| files / objects | ObjectStore { put, get, head, delete, list } (key objects) | objects-s3 (AWS S3, MinIO, R2 via S3 API) · objects-r2 (bucket binding) · createMemoryObjectStore (tests) |
| HTTP | Host { listen(handler) → HostServer } | host-node · host-fastify · host-cloudflare |
| tenancy | TenantResolver (request) → tenantId | tenantFromHeader, tenantFromSubdomain({ root, fallback, reserved }), your own |
| agent code | Executor { load(bundle, host, opts?) → ExecutorInstance } (in @sms/kernel) | executor-node · executor-quickjs |
| model | ModelService { 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.
- sqlite —
node:sqlite, one file per kernel (:memory:for tests), WAL on. - postgres —
pg.Poolfrom aconnectionString(creates the schema, pinssearch_pathper 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 increateCloudflareApp({ store }). Multi-line DDL is flattened becauseD1Database.execruns one statement per line. Use it when a tenant’s data must be reachable outside its Durable Object (dashboards,wrangler d1queries);store-dois 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.
- s3 —
createS3ObjectStore({ bucket, endpoint, region, accessKeyId, secretAccessKey, sessionToken, forcePathStyle }): Signature V4 over purefetch+ WebCrypto, no aws-sdk, runs in Node and Workers. One driver for AWS S3 (noendpoint), 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 areS3Error { status, code }. - r2 —
createR2ObjectStore(env.FILES)on a bucket binding inside a Worker / Durable Object: no credentials, streams pass straight through. StructuralR2BucketLiketype, 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.
- node —
createNodeHost({ port, host }).listen(handle);toRequest/sendResponseare exported for reuse. - fastify —
createFastifyHost({ app, prefix }).listen(handle)mounts a catch-all underprefixon your existing Fastify instance (raw bodies,reply.hijack()), or creates one. - cloudflare —
createCloudflareApp({ resolve, binding, tenant })returns the Workerfetchand a Durable Object class; each tenant’s DO opensstore-doon its SQLite and calls yourtenant({ tenantId, env, store }), which returns anything with ahandle(acreateBaseline()result). One traditional Worker, no Dynamic Workers.store: ({ tenantId, env, state }) => Storeswaps the DO SQLite for e.g.openD1Store(env.DB). The driver depends only on contracts, so it can host anyRequestHandler.
Executors
executor-node | executor-quickjs | |
|---|---|---|
| mechanism | worker_threads, code as a data: ESM import, ambient globals stripped | QuickJS interpreter compiled to WASM, one runtime per plugin |
| boundary | steering, not security (same process) | real: own heap, memoryLimitBytes, CPU deadline per slice (sliceMs), no I/O, no import |
| bridge | RPC channel (@sms/plugin-sdk) | JSON over one host function; cap built by an in-VM bootstrap |
| runs on | Node | Node, browsers, Workers (variant: import('@jitl/quickjs-singlefile-mjs-release-sync')) |
| use for | local dev, fastest | production |
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 (claudelogin). 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 eachcodex execat it. Onecodex execper turn, resumed by thread id;developer_instructionscarries 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 execforcesapproval_policy=neverand then only auto-approves MCP tools annotatedreadOnlyHint— 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 }); defaultclaude-opus-5, adaptive thinking). Thinking blocks are notModelContent, so the driver caches those that preceded a tool call and re-attaches them when the loop replays that assistant turn. Passclientto use a Bedrock/Vertex/Foundry client instead.@sms/model-gpt-api—openai, Responses API (createGptApiModel({ apiKey, model, effort }); defaultgpt-5.5,store: falsewith encrypted reasoning replayed the same way).@sms/model-openrouter—openaipointed athttps://openrouter.ai/api/v1, Chat Completions (createOpenRouterModel({ apiKey, model, effort, appName, siteUrl });modelis an OpenRouter slug, defaultanthropic/claude-sonnet-4.5;effortmaps to OpenRouter’s unifiedreasoning.effort,none…max). OpenRouter’s HTTP-200 errors (choices[0].error/finish_reason: 'error') are thrown. OpenRouter’sreasoning_detailsare 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 tokenscodex loginwrites to~/.codex/auth.jsonsent 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. Purefetch, so it runs in a Worker.createGptOauthModel({ unofficial: true, tokens, store?, model, effort });tokensis aCodexTokens, or the text ofauth.json(in Node:readCodexAuth()from@sms/model-gpt-oauth/node);store(kvTokenStore(kv), orfileTokenStore(path)from/node) persists refreshed tokens. Tokens are refreshed before expiry and once more on a 401.unofficial: trueis 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=1runs 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
- Implement the contract in a new package
packages/drivers/<kind>/<name>depending only on@sms/contracts(and@sms/kernelforExecutor). Never import a sibling driver — the gate (scripts/quality/layers.ts) allows only thestore-sqlhelper and the listed exceptions (host-fastify → host-node,host-cloudflare → store-do). - Reuse the shared tests: store drivers run
databaseContractandcollectionsContractTests; object stores runobjectsContract; executors copypackages/drivers/executor/quickjs/test/executor.test.ts; model drivers runmodelContractfrompackages/core/contracts/test. - Add it to
tsconfig.build.jsonreferences and keeppackage.jsonin the standard shape (exportswithsms-source/types/import,files: ["dist"]).