Skip to content
GitHub
Get started →

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 caseNative adapterWebhook
Want to reuse a DB connection pool across sessions✗ (your server would need its own)
Need sub-50ms query latencydepends on your hosting
Complex OAuth with refresh logicyou 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"]
}
}
}

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 object
  • matchesQuery(item, query) — case-insensitive substring search across string/array fields
  • sortAndLimit(items, params) — applies sort_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:

  1. Unit-test operator translation. Feed it SearchParams; assert on the query you’d emit.
  2. Integration-test against a real backend. Use a test instance with known data.
  3. E2E-test through the widget. Install on a test site, ask a question that hits your adapter, verify the response.
  4. Stress-test. 100 parallel search calls; check you don’t exhaust the pool.
  5. 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.