Context
Providers implement interfaces defined in core. They are selected at runtime via config
(e.g., STORAGE_PROVIDER_TYPE). Tier 3 providers lazy-load their dependencies to keep the
core bundle small.
Providers live inside the package source tree — import the interface via relative path
(e.g., import type { IStorageProvider } from '../core/IStorageProvider.js'), not via the
package subpath exports (those are for consumers).
Provider interfaces
| Domain | Interface file |
|---|---|
| Storage | src/storage/core/IStorageProvider.ts |
| LLM | src/services/llm/core/ILlmProvider.ts |
| Speech | src/services/speech/core/ISpeechProvider.ts |
Read the relevant interface fully before implementing — each has distinct required members.
ISpeechProvider in particular requires readonly name, readonly supportsTTS,
readonly supportsSTT, and healthCheck() in addition to the capability methods;
these flags drive routing in SpeechService.
File conventions
Provider file location and naming differ by domain:
Storage — nested subdirectory, camelCase directory name, PascalCase-suffixed provider file. Each provider gets its own subdirectory for the provider file plus any co-located types:
src/storage/providers/{{providerName}}/{{providerName}}Provider.ts(e.g.,src/storage/providers/inMemory/inMemoryProvider.ts,src/storage/providers/supabase/supabaseProvider.ts+supabase.types.ts)LLM / Speech — flat directory, kebab-case with
.provider.tssuffix:src/services/llm/providers/{{provider-name}}.provider.tssrc/services/speech/providers/{{provider-name}}.provider.ts(e.g.,src/services/llm/providers/openrouter.provider.ts,src/services/speech/providers/elevenlabs.provider.ts)
Steps
Identify the provider interface — read the interface file for the target domain (see table above).
Create the provider file following the file convention for its domain (see above).
Implement the interface — all methods must be implemented. Storage providers build on
src/storage/core/providerHelpers.tsrather than re-deriving what the existing providers share:getManyViaGet/setManyViaSet/deleteManyViaDelete— the batch methods as a parallel fan-out over the single-key methods, for backends with no native batch API.encodeEnvelope/decodeEnvelope— the TTL envelope for backends with no TTL of their own (R2, filesystem).decodeEnvelopereturns{ kind: 'expired' }so the provider can delete on read, returns pre-envelope JSON as a plain value, and throwsSyntaxErroron invalid JSON so the provider can attach the key to the error it raises.paginateSortedKeys— onelist()page over an already-sorted key set, with the cursor for the page that follows.escapeLikePattern— escapes%,_, and\in a prefix before a SQLLIKE.
Lazy-load dependencies if Tier 3:
let _client: SomeClient | undefined; async function getClient(): Promise<SomeClient> { if (!_client) { const { SomeClient } = await import('some-package'); _client = new SomeClient(/* config */); } return _client; }Register the provider — the registration point differs by domain:
Storage — two changes required:
- Add the new provider string to the
z.enumforSTORAGE_PROVIDER_TYPEinsrc/config/index.ts— without this, the config schema rejects the env var at runtime. - Add a
caseto theswitchinsrc/storage/core/storageFactory.tsinsidecreateStorageProvider(). Import the new provider class at the top of that file.
- Add the new provider string to the
Speech — two changes required:
- Add the new provider string literal to the
providerunion inSpeechProviderConfig(src/services/speech/types.ts, fieldprovider). - Add a
caseto theswitchincreateSpeechProvider()(src/services/speech/core/SpeechService.ts). Import the new provider class at the top of that file.
- Add the new provider string literal to the
LLM — currently only one provider exists (
OpenRouterProvider); it is instantiated directly insrc/core/app.tsrather than through a factory switch. There is no factory pattern yet — adding a second provider requires introducing one (a selector env var, a factory function, and a conditional inapp.ts). Readsrc/core/app.tsto understand the current instantiation site before designing the wiring.
Update the Worker-compatible provider list if the new storage provider runs in Cloudflare Workers. The list is an inline array in
storageFactory.tsat theisServerless()guard:// src/storage/core/storageFactory.ts !['in-memory', 'cloudflare-r2', 'cloudflare-kv', 'cloudflare-d1'].includes(providerType)Add the new provider string to this array. Non-storage providers have no equivalent gate.
Add the dependency if Tier 3: add to both
peerDependenciesandpeerDependenciesMeta(with{ "optional": true }) inpackage.json. Without thepeerDependenciesMetaentry, the dep appears required rather than optional.Run
bun run rebuild— since this is package source, verify the build output compiles.Run
bun run devcheckto verify.
Checklist
- Provider file created with JSDoc
@fileoverview+@moduleheader - Interface fully implemented (including
name,supportsTTS/supportsSTTfor speech) - Tier 3 dependencies lazy-loaded (not top-level imports)
- Registered in the correct factory for the domain (see Step 5)
- Storage: provider string added to
z.enuminsrc/config/index.ts - Storage: Worker-compatible array in
storageFactory.tsupdated if applicable - Storage: batch, TTL envelope, paging, and
LIKEescaping come fromproviderHelpers.ts, not a local copy - Speech:
providerliteral added toSpeechProviderConfigunion intypes.ts - LLM:
src/core/app.tsinstantiation logic updated if adding a second LLM provider - Optional peer dependency added to both
peerDependenciesandpeerDependenciesMetainpackage.jsonif Tier 3 -
bun run rebuildsucceeds -
bun run devcheckpasses - Tests added under
tests/unit/storage/providers/{{providerName}}/(storage) ortests/unit/services/{{domain}}/providers/{{provider-name}}.provider.test.ts(LLM / speech), andbun run testpasses