Build a custom adapter (TypeScript)
For most customers, the webhook adapter is the right escape hatch — one endpoint in any language, ~30 minutes of work.
For advanced cases — connection pooling, connection reuse across many voice sessions, complex auth flows, in-process caches — you can implement the full DataBridgeAdapter interface in TypeScript. Expect about 2 hours of work.
When to choose this over a webhook
| Use case | Native adapter | Webhook |
|---|---|---|
| Want to reuse a DB connection pool across sessions | ✓ | ✗ (your server would need its own) |
| Need sub-50ms query latency | ✓ | depends on your hosting |
| Complex OAuth with refresh logic | ✓ | you implement it yourself |
| Using a language other than TypeScript | ✗ | ✓ |
| Want to deploy/scale separately from Spelo | ✗ | ✓ |
| Simpler to maintain | — | ✓ |
If you’re unsure, start with the webhook. You can migrate later.
The interface
From packages/shared/src/types.ts:
export interface DataBridgeAdapter { readonly type: string; search(params: SearchParams): Promise<SearchResult>; testConnection(): Promise<{ ok: boolean; error?: string }>; describeSchema?(): Promise<SchemaDescription>; close?(): Promise<void>;}And the payload shapes:
export interface SearchParams { collection: string; query?: string; filters?: Filter[]; sort_by?: string; sort_direction?: 'asc' | 'desc'; limit?: number;}
export interface SearchResult { success: boolean; total: number; returned: number; items: Record<string, unknown>[]; error?: string;}
export interface Filter { field: string; operator: 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'contains' | 'in'; value: string | number | boolean | string[] | number[];}Step 1 — Create the adapter file
packages/adapters/src/mycompany.ts:
import type { CollectionConfig, DataBridgeAdapter, SearchParams, SearchResult,} from '@spelo/shared';
export interface MyCompanyAdapterConfig { apiKey: string; region: string;}
export class MyCompanyAdapter implements DataBridgeAdapter { readonly type = 'mycompany';
constructor( private config: MyCompanyAdapterConfig, private collections: Record<string, CollectionConfig> = {} ) { if (!config?.apiKey) { throw new Error('MyCompanyAdapter: apiKey is required'); } // Initialize your client/connection pool here }
async search(params: SearchParams): Promise<SearchResult> { try { const coll = this.collections[params.collection]; if (!coll) { return { success: false, total: 0, returned: 0, items: [], error: `Unknown collection: ${params.collection}`, }; }
// 1. Validate field names against the whitelist const filterable = new Set(coll.filterable_fields ?? []); for (const f of params.filters ?? []) { if (filterable.size > 0 && !filterable.has(f.field)) { throw new Error( `Field "${f.field}" is not filterable for collection "${params.collection}"` ); } }
// 2. Translate filters / query into your backend's query language // 3. Execute (parameterized!) // 4. Shape results to SearchResult
return { success: true, total: 42, returned: 5, items: [/* your rows */], }; } catch (err) { return { success: false, total: 0, returned: 0, items: [], error: err instanceof Error ? err.message : String(err), }; } }
async testConnection(): Promise<{ ok: boolean; error?: string }> { try { // Ping your backend return { ok: true }; } catch (err) { return { ok: false, error: err instanceof Error ? err.message : String(err), }; } }
/** Optional — used by site-intelligence to tell the AI your schema. */ async describeSchema() { return { collections: [ { name: 'items', source: 'items', fields: [ { name: 'id', type: 'string' as const }, { name: 'title', type: 'string' as const }, { name: 'price', type: 'number' as const }, ], }, ], }; }
async close() { // Clean up connections }}Step 2 — Register it in the factory
packages/adapters/src/index.ts:
export type AdapterType = | 'json' | 'postgres' // ... existing adapters | 'mycompany';
export async function createAdapter(spec: AdapterSpec): Promise<DataBridgeAdapter> { switch (spec.type) { // ... existing cases case 'mycompany': { const mod = await import('./mycompany'); return new mod.MyCompanyAdapter(spec.config, spec.collections ?? {}); } }}Step 3 — Configure
{ "type": "mycompany", "config": { "apiKey": "...", "region": "us-east-1" }, "collections": { "items": { "source": "items", "filterable_fields": ["price", "category"] } }}Step 4 — Implement describeSchema (optional but recommended)
If your backend exposes introspection (information_schema, OpenAPI spec, JSON-RPC reflection), implement describeSchema(). The result gets fed into the AI’s system prompt so it knows what fields are available to query.
Reusable helpers
All adapters can import helpers from ./base:
matchesFilter(item, filter)— applies one filter to an objectmatchesQuery(item, query)— case-insensitive substring search across string/array fieldssortAndLimit(items, params)— appliessort_by,sort_direction,limit
Example — a hybrid adapter that fetches everything and filters client-side:
async search(params: SearchParams): Promise<SearchResult> { const all = await this.fetchAllItems(params.collection); const filtered = all.filter((item) => { for (const f of params.filters ?? []) if (!matchesFilter(item, f)) return false; if (params.query && !matchesQuery(item, params.query)) return false; return true; }); const items = sortAndLimit(filtered, params); return { success: true, total: filtered.length, returned: items.length, items };}Testing
Before shipping:
- Unit-test operator translation. Feed it
SearchParams; assert on the query you’d emit. - Integration-test against a real backend. Use a test instance with known data.
- E2E-test through the widget. Install on a test site, ask a question that hits your adapter, verify the response.
- Stress-test. 100 parallel search calls; check you don’t exhaust the pool.
- Security-audit. Parameterized queries, identifier whitelisting, escaped free-text. Never let a filter value become executable code.
Security rules (always)
Submitting an adapter upstream
If you build something useful (e.g. an Elastic adapter, Meilisearch, Algolia, ClickHouse), we’re happy to merge it into the core. Open a PR against packages/adapters/ with:
- The adapter file (
src/YourAdapter.ts) - A test file (
test/YourAdapter.test.ts) - A docs page under
apps/docs/src/content/docs/data/
Contact docs@spelo.ai before starting if you want feedback on the approach.