← Docs

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 no browse in your provider, there's no browse.
  • buildContext: When omitted, the framework lists connected non-account assets (repos, servers, etc.) in the context. Override buildContext for custom context.
  • Logo & website: Declared inline on the provider — no need to touch logos.ts or website-metadata.ts.
  • Account asset boilerplate: defineApiKeyAccount() replaces ~40 lines of defineAssetType() + 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 methodHow it works
formiOS shows a form with the declared fields. Server creates the asset.
oauthOpens an OAuth flow. Set oauthProvider and optionally service.
device_codeDevice 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:

  1. Create server/src/routes/auth-acme.ts — handles /auth/acme/callback
  2. Register it in server/src/index.ts
  3. Set auth.type: "oauth" and auth.scopes on 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.ts passes
  • [ ] (If OAuth) routes/auth-acme.ts + registered in index.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:

  1. Finds all account assets for your provider in the scene
  2. Calls createJobClient() for each account → gets a client per account
  3. Collects tools from getTools() and asset type tools declarations
  4. If multiple accounts: injects accountId parameter into every tool
  5. When LLM calls a tool: routes to the right client based on accountId
  6. Builds context message (custom buildContext or default asset listing)

You don't write routing, context building, or multi-account logic.

Auth types

TypeHow it works
api_keyUser enters key in iOS. Stored in account asset fields. Framework passes to createJobClient().
oauthOAuth flow via routes/auth-{provider}.ts. Token stored in account asset.
customProvider-specific fields (S3 needs endpoint + key + secret + bucket).
noneNo credentials. Public APIs (Polymarket, etc.).
deviceTools 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 account asset type with scope: "org".
  • Child types use parent to establish hierarchy. iOS shows parent → child
navigation automatically.
  • id is 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

ConceptFormatExample
Asset type in DB{provider}/{typeId}acme/widget, github/repository
Account ID in browse{provider}:{assetId}acme:ast_abc123
External IDProvider's native IDwidget-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

ComplexityProviderWhat it shows
Minimalbrave/index.ts1 tool, API key auth, inline logo/website
Simplefirecrawl/index.ts6 tools, defineApiKeyAccount, auto browse
Moderatereplicate/index.tsMulti-asset (account + model + prediction), browse
Fullhetzner/index.ts15 tools, SSH execution, server lifecycle
OAuthslack/index.tsOAuth with bot token, channel browsing
Escape hatche2b/index.tsCustom initialize() with sandbox management