Building an Integration
An integration (called a "provider" in code) connects an external service
to Daslab. When you're done, it appears in the iOS app alongside Gmail, GitHub,
and Slack. Users connect with one tap, browse resources visually, and your AI
tools are available in every conversation. A simple integration takes about
30 minutes.
Minimal working integration
Here's a complete integration in ~30 lines. Users connect an API key and the
AI can search widgets in chat.
server/src/providers/
├── acme/
│ └── index.ts ← you write this
├── base.ts ← types & helpers (ToolDeclaration, defineApiKeyAccount, etc.)
├── unified.ts ← framework (auto-wires everything)
└── registry.ts ← registration (you add one import + one line)
Create server/src/providers/acme/index.ts:
import { defineApiKeyAccount, type ToolDeclaration } from "../base";
import { defineUnifiedProvider } from "../unified";
import type { MCPToolResult } from "../../job-provider";
const account = defineApiKeyAccount({
provider: "Acme",
keyDescription: "From https://acme.example/settings/api",
});
const ACME_SEARCH: ToolDeclaration = {
name: "acme_search",
description: "Search Acme widgets by name or status.",
readOnly: true,
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Search query" },
},
required: ["query"],
},
};
export default defineUnifiedProvider({
id: "acme",
name: "Acme",
icon: "cube",
color: "FF6B35",
status: "active",
logo: { type: "brandfetch", domain: "acme.example" },
website: {
tagline: "Widget management for teams",
description: "Connect your Acme account to manage widgets with AI.",
category: "productivity",
useCases: ["List and filter widgets", "Create widgets from chat"],
public: true,
docsUrl: "https://docs.acme.example",
},
auth: { type: "api_key", credentialField: "api_key" },
requiresConnection: true,
instructionText: "Enter your Acme API key from https://acme.example/settings/api",
dashboardUrl: "https://acme.example/settings/api",
assetTypes: [account],
createJobClient: async (config) => config.credential,
getTools: () => [ACME_SEARCH],
executeTool: async (apiKey: string, name: string, input: Record<string, unknown>): Promise<MCPToolResult> => {
const res = await fetch(`https://api.acme.example/search?q=${encodeURIComponent(String(input.query))}`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
const data = await res.json();
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
},
});
Register it in server/src/providers/registry.ts:
import acmeProvider from "./acme";
// Inside the constructor:
this.registerUnified(acmeProvider);
That's it — two files. Run the server, open the iOS app, connect an API key,
and ask the AI to search Acme. The framework handles credential injection, tool
routing, context building, and multi-account support.
# Verify it works
bun test src/providers/job-registration.test.ts
What you get for free
Helpers and framework behavior — all explicitly visible in your provider file:
- browse: Write your own, or use the
accountBrowse()helper for account-only providers. Nothing is auto-generated — if there's nobrowsein your provider, there's no browse. - buildContext: When omitted, the framework lists connected non-account assets (repos, servers, etc.) in the context. Override
buildContextfor custom context. - Logo & website: Declared inline on the provider — no need to touch
logos.tsorwebsite-metadata.ts. - Account asset boilerplate:
defineApiKeyAccount()replaces ~40 lines ofdefineAssetType()+ widget code.
defineApiKeyAccount helper
Most API-key providers use the exact same account asset pattern. The helper
reduces ~40 lines to ~5:
import { defineApiKeyAccount } from "../base";
// Standard (API Key, display name field, auto-generated widget)
const account = defineApiKeyAccount({
provider: "Firecrawl",
keyDescription: "From https://firecrawl.dev/app/api-keys",
});
// With variations
const account = defineApiKeyAccount({
provider: "Apify",
keyLabel: "API Token", // Default: "API Key"
keyDescription: "From https://console.apify.com/account/integrations",
dashboardUrl: "https://...", // "Open Dashboard" link in widget
includeDisplayName: false, // Omit display name field
buttonLabel: "Add API Key", // Custom create button text
buttonSubtitle: "Use your own key",
widget: { ... }, // Full widget override (for live validation)
});
The auto-generated widget checks !accessToken and returns
accountHealthy()/accountUnhealthy(). Override widget when you need live
credential validation (e.g., Apify validates the token via API call).
Adding more tools
Tools are declared as ToolDeclaration constants and returned from getTools().
Account-level tools need credentials but not a specific resource.
const ACME_LIST: ToolDeclaration = {
name: "acme_list_widgets",
description: "List all widgets in the account.",
readOnly: true,
inputSchema: {
type: "object",
properties: {
status: { type: "string", enum: ["active", "archived", "all"] },
},
},
};
const ACME_CREATE: ToolDeclaration = {
name: "acme_create_widget",
description: "Create a new widget.",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "Widget name" },
color: { type: "string", description: "Hex color code" },
},
required: ["name"],
},
};
Tools can also be declared on asset types (for resource-level tools):
const widget = defineAssetType({
id: "widget",
name: "Widget",
namePlural: "Widgets",
icon: "cube",
parent: "account",
tools: [
{
name: "acme_get_widget",
description: "Get details of a specific widget.",
readOnly: true,
inputSchema: {
type: "object",
properties: {
widget_id: {
type: "string",
description: "The widget ID",
assetType: "acme_widget", // typed reference — iOS shows asset picker
},
},
required: ["widget_id"],
},
},
],
fields: [
{ key: "name", label: "Name", type: "string" },
{ key: "status", label: "Status", type: "string" },
],
display: {
list: {
title: "{{name}}",
subtitle: "{{status}}",
},
},
});
The assetType property on input schema fields is Daslab's key extension over
MCP. It says "this parameter references an acme_widget asset" — enabling iOS
to show filtered asset pickers and the website to show "operates on: Widget".
Tool names follow {provider}_{verb}_{noun}: acme_list_widgets,
s3_get_object, gmail_search.
Adding browseable resources
For account-only providers, use the accountBrowse() helper:
import { accountBrowse } from "../../lib/account-assets.js";
export default defineUnifiedProvider({
// ...
browse: accountBrowse("acme", "Acme Account"),
});
For providers with additional resource types, write a browse function:
import { type BrowseParams, type BrowseResult } from "../../lib/browse.js";
import { getAccountAssets, browseAccounts } from "../../lib/account-assets.js";
async function browse(params: BrowseParams): Promise<BrowseResult> {
const allAccounts = await getAccountAssets(params.sceneId, "acme");
if (allAccounts.length === 0) {
return { items: [], needsConnection: true, message: "Connect your Acme account to browse widgets." };
}
// type=account → one-liner via helper
if (params.type === "account") {
return browseAccounts(params.sceneId, "acme", { defaultName: "Acme Account" });
}
// type=widget → fetch from your API
const apiKey = allAccounts[0].fields?.api_key;
if (!apiKey) return { items: [], error: "No API key found" };
const res = await fetch("https://api.acme.example/widgets", {
headers: { Authorization: `Bearer ${apiKey}` },
});
const widgets = await res.json();
return {
items: widgets.map((w: any) => ({
id: w.id,
name: w.name,
description: w.status,
metadata: { status: w.status },
})),
};
}
Pass browse to defineUnifiedProvider({ ..., browse }).
Browse params
params.sceneId // the organization
params.type // which asset type ("account", "widget", etc.)
params.accountId // "acme:{assetId}" — filter by account
params.parentId // parent asset ID (for hierarchical browsing)
params.search // search query (if hasSearch: true)
params.extra // additional query params (path, prefix, etc.)
Browse return shape
{
items: [
{
id: "widget-123", // unique ID (becomes external_id in DB)
name: "My Widget", // display name
description: "Active", // subtitle
accountId: "acme:ast_abc", // which account owns this
parentId: "...", // parent asset (for hierarchical types)
metadata: { ... }, // stored in asset fields
},
],
accounts: [ // account filter dropdown
{ id: "acme:ast_abc", name: "My Acme Account" },
],
needsConnection: false, // true → shows "Connect account" prompt
message: "...", // shown when items is empty
error: "...", // shown as error banner
}
CRUD operations
Asset types can declare crud to enable create, edit, and delete in the iOS
asset browser.
Create
crud: {
create: {
method: "form",
fields: [
{ id: "api_key", label: "API Key", type: { kind: "secret" }, required: true },
{ id: "name", label: "Name", type: { kind: "text", placeholder: "My account" } },
],
buttonLabel: "Add Account", // custom button text
buttonSubtitle: "Enter your API key", // subtitle below button
},
}
| Create method | How it works |
|---|---|
form | iOS shows a form with the declared fields. Server creates the asset. |
oauth | Opens an OAuth flow. Set oauthProvider and optionally service. |
device_code | Device authorization code flow (e.g., OpenAI Codex). |
Edit
crud: {
edit: true, // reuses create.fields for editing
// or:
edit: { fields: [...] }, // custom edit form
}
Delete
All assets live in our assets table. Deleting always removes from the
database. The server handles this generically via POST /api/providers/:provider/crud.
// Daslab-managed asset (account, search filter, etc.)
// → just deletes from our DB
crud: {
delete: { confirm: "Remove this account?" },
}
// Provider-managed asset (Google Sheet, GitHub repo, etc.)
// → calls the provider tool first, then deletes from our DB
crud: {
delete: {
confirm: "Delete this spreadsheet from Google Drive?",
tool: "sheets_delete_spreadsheet",
},
}
Enrichment and health
For asset types with live state (PRs can be merged, servers can go down):
const widget = defineAssetType({
id: "widget",
// ...
enrich: {
fetch: async (asset, client, accessToken) => {
const res = await fetch(`https://api.acme.example/widgets/${asset.external_id}`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
return res.json();
},
},
health: {
rules: [
{ when: (w) => w.status === "active", status: "green", reason: "Active" },
{ when: (w) => w.status === "degraded", status: "yellow", reason: "Degraded" },
{ when: (w) => w.status === "down", status: "red", reason: "Down" },
],
default: { status: "yellow", reason: "Unknown status" },
},
preview: {
extract: (w) => ({
status: w.status,
created: w.created_at,
owner: w.owner?.name,
}),
},
});
OAuth providers
OAuth providers need an additional route file:
- Create
server/src/routes/auth-acme.ts— handles/auth/acme/callback - Register it in
server/src/index.ts - Set
auth.type: "oauth"andauth.scopeson the provider
If your provider shares another provider's OAuth (e.g., Gmail uses Google OAuth),
set auth.oauthProvider: "google" instead of building a new route.
export default defineUnifiedProvider({
// ...
auth: {
type: "oauth",
oauthProvider: "google", // reuse Google's OAuth flow
scopes: ["https://www.googleapis.com/auth/gmail.readonly"],
},
});
The escape hatch
For complex providers (E2B sandboxes, GitHub with MCP + custom tools, Google
with token refresh), use initialize() to take full control:
export default defineUnifiedProvider({
// ... identity, auth, assetTypes ...
initialize: async (ctx) => {
const account = ctx.assets.find(a => a.type === "acme_account");
if (!account?.fields?.api_key) return null;
const client = new AcmeClient(account.fields.api_key);
return {
client: {
executeTool: (name, input) => client.executeTool(name, input),
executeToolStreaming: (name, input) => client.stream(name, input),
},
tools: [...],
contextMessage: "You have access to Acme. Use acme_ tools to manage widgets.",
streamingToolNames: ["acme_stream_widgets"],
};
},
browse,
});
When initialize is present, the framework calls it instead of the declarative
createJobClient + executeTool flow. Use this only when you need custom
initialization, streaming, or MCP client management.
Logo sources
Logos are declared inline on the provider via the logo field. Three sources:
logo: { type: "simpleIcons", slug: "brave" }, // Simple Icons CDN (SVG, best)
logo: { type: "brandfetch", domain: "firecrawl.dev" }, // Brandfetch (PNG, good)
logo: { type: "url", url: "https://..." }, // Direct URL (any format)
The framework handles SVG→PNG conversion, Redis caching, and CDN fallbacks.
No need to touch logos.ts.
E2E test factory
Use describeProviderE2E() to generate standard E2E tests from ~20 lines
of config (replaces ~170 lines of boilerplate):
import { describeProviderE2E } from "../../test-utils.js";
describeProviderE2E({
providerId: "acme",
providerName: "Acme",
icon: "cube",
chatTests: [
{
name: "should search widgets via chat",
prompt: "Use acme_search to find widgets about dashboards",
expectToolMatch: "acme",
},
],
});
This generates:
- Scene creation + cleanup
- Asset management tests (add account, list assets, verify provider connected)
- Tool execution via chat tests with tool call assertions
Checklist
- [ ]
providers/acme/index.ts— provider with asset types, tools, and execution - [ ]
providers/registry.ts— import +this.registerUnified(acmeProvider) - [ ]
bun test src/providers/job-registration.test.tspasses - [ ] (If OAuth)
routes/auth-acme.ts+ registered inindex.ts
That's it. Logo and website metadata are declared inline on the provider.
Browse uses the accountBrowse() helper or a custom function. Context is
built from your buildContext or defaults to listing connected assets.
Reference
The declarative flow
When createJobClient and executeTool are provided (no initialize), the
framework does everything automatically:
- Finds all account assets for your provider in the scene
- Calls
createJobClient()for each account → gets a client per account - Collects tools from
getTools()and asset typetoolsdeclarations - If multiple accounts: injects
accountIdparameter into every tool - When LLM calls a tool: routes to the right client based on
accountId - Builds context message (custom
buildContextor default asset listing)
You don't write routing, context building, or multi-account logic.
Auth types
| Type | How it works |
|---|---|
api_key | User enters key in iOS. Stored in account asset fields. Framework passes to createJobClient(). |
oauth | OAuth flow via routes/auth-{provider}.ts. Token stored in account asset. |
custom | Provider-specific fields (S3 needs endpoint + key + secret + bucket). |
none | No credentials. Public APIs (Polymarket, etc.). |
device | Tools execute on the iOS device, not the server. |
Asset type hierarchy
Provider: acme
├── account (scope: org) ← credentials live here
├── widget (parent: account) ← browseable resources
│ └── sub-widget (parent: widget) ← nested hierarchy
└── ...
Rules:
- Every provider needs an
accountasset type withscope: "org". - Child types use
parentto establish hierarchy. iOS shows parent → child
idis bare — just"widget", not"acme_widget". The framework prefixes it.
Full defineUnifiedProvider type
defineUnifiedProvider<TClient>({
id: string;
name: string;
icon: string; // SF Symbols name
color: string; // hex without #
status?: "active" | "coming_soon";
logo?: LogoSource; // { type: "simpleIcons"|"brandfetch"|"url", ... }
website?: ProviderWebsite; // tagline, description, category, useCases, public
auth: {
type: "api_key" | "oauth" | "custom" | "none" | "device";
credentialField?: string; // default: "api_key"
scopes?: string[]; // OAuth scopes
oauthProvider?: string; // share another provider's OAuth
};
requiresConnection?: boolean;
instructionText?: string; // shown in iOS connection sheet
dashboardUrl?: string; // link to get API key
supportsMultipleConnections?: boolean;
assetTypes: AssetTypeDefinition[];
// Declarative flow
createJobClient?: (config: ClientConfig) => Promise<TClient | null>;
getTools?: (client: TClient) => ToolDeclaration[];
executeTool?: (client: TClient, name: string, input: Record<string, unknown>) => Promise<MCPToolResult>;
disconnect?: (client: TClient) => Promise<void>;
buildContext?: (clients, assets, accountAssets) => string;
// Escape hatch (replaces declarative flow)
initialize?: (ctx: JobProviderContext) => Promise<JobProviderResult | null>;
browse?: (params: BrowseParams) => Promise<BrowseResult>; // use accountBrowse() helper for account-only
})
Full defineAssetType type
defineAssetType({
id: string; // "widget" (framework prefixes to "acme_widget")
name: string; // "Widget"
namePlural: string; // "Widgets"
description?: string;
icon: string; // SF Symbols name
color?: string; // hex without #
scope?: "org" | "scene"; // "org" for accounts, "scene" for resources (default)
parent?: string; // parent asset type ID
hasSearch?: boolean; // enables search in browse UI
groupByParent?: boolean; // group items by parent in browse
tools?: ToolDeclaration[]; // tools that operate on this asset type
fields?: FieldDefinition[]; // display fields (shown in iOS asset cards)
configFields?: ConfigFieldDefinition[]; // user-editable config per instance
crud?: {
create?: {
method: "form" | "oauth" | "device_code";
fields?: ConfigFieldDefinition[]; // form fields
oauthProvider?: string; // for method: "oauth"
service?: string; // for method: "oauth"
buttonLabel?: string;
buttonSubtitle?: string;
};
edit?: true | { fields: ConfigFieldDefinition[] };
delete?: {
confirm?: string; // confirmation message
tool?: string; // MCP tool to call before DB delete
};
};
browse?: {
mapResult: (raw: any, params?: BrowseParams) => BrowsableItem;
};
enrich?: {
fetch: (asset: Asset, client: any, accessToken: string) => Promise<any>;
};
health?: {
rules: Array<{
when: (data: any) => boolean;
status: "green" | "yellow" | "red";
reason: string;
}>;
default: { status: string; reason: string };
};
preview?: {
extract: (data: any) => Record<string, any>;
};
display?: {
list?: { title: string; subtitle?: string }; // template strings: "{{name}}"
card?: { ... };
};
actions?: ActionConfig[]; // quick actions in iOS
})
defineApiKeyAccount options
defineApiKeyAccount({
provider: string; // Display name (e.g. "Firecrawl")
keyDescription: string; // Help text (e.g. "From https://...")
keyLabel?: string; // Default: "API Key". Use "API Token" for some providers
keyPlaceholder?: string; // Placeholder in key input (e.g. "sk-...", "sk-ant-...")
description?: string; // Override default asset description
deleteConfirm?: string; // Override confirmation text
dashboardUrl?: string; // URL for widget "Open Dashboard" link
icon?: string; // Override "person.badge.key.fill"
includeDisplayName?: boolean; // Include Display Name field (default: true)
buttonLabel?: string; // Custom create button label
buttonSubtitle?: string; // Custom create button subtitle
widget?: { ... }; // Full widget override (for live validation)
})
Database conventions
| Concept | Format | Example |
|---|---|---|
| Asset type in DB | {provider}/{typeId} | acme/widget, github/repository |
| Account ID in browse | {provider}:{assetId} | acme:ast_abc123 |
| External ID | Provider's native ID | widget-123, octocat/hello-world |
Naming conventions
- Provider ID: lowercase, no hyphens (
acme,googlemaps,e2b) - Asset type ID: lowercase, underscores OK (
pull_request,search_filter) - Tool names:
{provider}_{verb}_{noun}(acme_list_widgets,s3_get_object) - Icon: SF Symbols name (
cube,person.badge.key.fill,magnifyingglass) - Color: 6-char hex without
#(FF6B35,24292e)
Real examples to study
| Complexity | Provider | What it shows |
|---|---|---|
| Minimal | brave/index.ts | 1 tool, API key auth, inline logo/website |
| Simple | firecrawl/index.ts | 6 tools, defineApiKeyAccount, auto browse |
| Moderate | replicate/index.ts | Multi-asset (account + model + prediction), browse |
| Full | hetzner/index.ts | 15 tools, SSH execution, server lifecycle |
| OAuth | slack/index.ts | OAuth with bot token, channel browsing |
| Escape hatch | e2b/index.ts | Custom initialize() with sandbox management |