Files
simstudioai--sim/.claude/commands/add-connector.md
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

27 KiB

description, argument-hint
description argument-hint
Add a knowledge base connector for syncing documents from an external source <service-name> [api-docs-url]

Add Connector Skill

You are an expert at adding knowledge base connectors to Sim. A connector syncs documents from an external source (Confluence, Google Drive, Notion, etc.) into a knowledge base.

Your Task

When the user asks you to create a connector:

  1. Use Context7 or WebFetch to read the service's API documentation
  2. Determine the auth mode: OAuth (if Sim already has an OAuth provider for the service) or API key (if the service uses API key / Bearer token auth)
  3. Create the connector directory: a client-safe meta.ts (declarative metadata) plus the runtime module that spreads it
  4. Register it in BOTH the server registry and the client-safe meta registry

Directory Structure

Each connector is split into a client-safe metadata file and a server-only runtime file. This mirrors the XBlockMeta / BLOCK_META_REGISTRY split in apps/sim/blocks — client components (the knowledge UI) only need the metadata (icon, name, auth, config fields), so the runtime functions (which pull server-only helpers like input-validation.serverundicinode:net) must stay out of the client bundle.

Create files in apps/sim/connectors/{service}/:

connectors/{service}/
├── index.ts          # Barrel export (re-exports the runtime connector)
├── meta.ts           # ConnectorMeta — client-safe declarative metadata
└── {service}.ts      # ConnectorConfig — spreads the meta + adds runtime functions
  • meta.ts exports {service}ConnectorMeta: ConnectorMeta. It imports ONLY the icon from @/components/icons, import type { ConnectorMeta } from '@/connectors/types', and any pure-data constants. It must NEVER import server/runtime code.
  • {service}.ts exports {service}Connector: ConnectorConfig. It imports the meta via import { {service}ConnectorMeta } from '@/connectors/{service}/meta', spreads it as the first property, and holds the runtime functions (which may import server-only helpers like @/lib/knowledge/documents/utils).

Authentication

Connectors use a discriminated union for auth config (ConnectorAuthConfig in connectors/types.ts):

type ConnectorAuthConfig =
  | { mode: 'oauth'; provider: OAuthService; requiredScopes?: string[] }
  | { mode: 'apiKey'; label?: string; placeholder?: string }

OAuth mode

For services with existing OAuth providers in apps/sim/lib/oauth/types.ts. The provider must match an OAuthService. The modal shows a credential picker and handles token refresh automatically.

API key mode

For services that use API key / Bearer token auth. The modal shows a password input with the configured label and placeholder. The API key is encrypted at rest using AES-256-GCM and stored in a dedicated encryptedApiKey column on the connector record. The sync engine decrypts it automatically — connectors receive the raw access token in listDocuments, getDocument, and validateConfig.

Connector Structure (meta.ts + runtime)

The declarative metadata lives in meta.ts (ConnectorMeta). The runtime functions live in {service}.ts (ConnectorConfig), which spreads the meta as its first property.

meta.ts — client-safe metadata

import { {Service}Icon } from '@/components/icons'
import type { ConnectorMeta } from '@/connectors/types'

export const {service}ConnectorMeta: ConnectorMeta = {
  id: '{service}',
  name: '{Service}',
  description: 'Sync documents from {Service} into your knowledge base',
  version: '1.0.0',
  icon: {Service}Icon,

  auth: {
    mode: 'oauth',
    provider: '{service}',          // Must match OAuthService in lib/oauth/types.ts
    requiredScopes: ['read:...'],
  },

  configFields: [
    // Rendered dynamically by the add-connector modal UI
    // Supports 'short-input', 'dropdown', and 'selector' types — see ConfigField Types below
  ],

  // Optional: tag definitions are metadata too — declare them here
  // tagDefinitions: [ ... ],
}

Keep meta.ts free of any server/runtime import. Only the icon, the ConnectorMeta type, and pure-data constants belong here.

{service}.ts — runtime (OAuth example)

import { createLogger } from '@sim/logger'
import { fetchWithRetry } from '@/lib/knowledge/documents/utils'
import { {service}ConnectorMeta } from '@/connectors/{service}/meta'
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'

const logger = createLogger('{Service}Connector')

export const {service}Connector: ConnectorConfig = {
  ...{service}ConnectorMeta,

  listDocuments: async (accessToken, sourceConfig, cursor) => {
    // Return metadata stubs with contentDeferred: true (if per-doc content fetch needed)
    // Or full documents with content (if list API returns content inline)
    // Return { documents: ExternalDocument[], nextCursor?, hasMore }
  },

  getDocument: async (accessToken, sourceConfig, externalId) => {
    // Fetch full content for a single document
    // Return ExternalDocument with contentDeferred: false, or null
  },

  validateConfig: async (accessToken, sourceConfig) => {
    // Return { valid: true } or { valid: false, error: 'message' }
  },

  // Optional: map source metadata to semantic tag keys (translated to slots by sync engine)
  mapTags: (metadata) => {
    // Return Record<string, unknown> with keys matching tagDefinitions[].id
  },
}

API key connector example

The split is identical — auth lives in meta.ts, runtime functions in {service}.ts.

// meta.ts
export const {service}ConnectorMeta: ConnectorMeta = {
  id: '{service}',
  name: '{Service}',
  description: 'Sync documents from {Service} into your knowledge base',
  version: '1.0.0',
  icon: {Service}Icon,

  auth: {
    mode: 'apiKey',
    label: 'API Key',                       // Shown above the input field
    placeholder: 'Enter your {Service} API key',  // Input placeholder
  },

  configFields: [ /* ... */ ],
}

// {service}.ts
export const {service}Connector: ConnectorConfig = {
  ...{service}ConnectorMeta,
  listDocuments: async (accessToken, sourceConfig, cursor) => { /* ... */ },
  getDocument: async (accessToken, sourceConfig, externalId) => { /* ... */ },
  validateConfig: async (accessToken, sourceConfig) => { /* ... */ },
}

ConfigField Types

The add-connector modal renders these automatically — no custom UI needed.

Three field types are supported: short-input, dropdown, and selector.

// Text input
{
  id: 'domain',
  title: 'Domain',
  type: 'short-input',
  placeholder: 'yoursite.example.com',
  required: true,
}

// Dropdown (static options)
{
  id: 'contentType',
  title: 'Content Type',
  type: 'dropdown',
  required: false,
  options: [
    { label: 'Pages only', id: 'page' },
    { label: 'Blog posts only', id: 'blogpost' },
    { label: 'All content', id: 'all' },
  ],
}

Dynamic Selectors (Canonical Pairs)

Use type: 'selector' to fetch options dynamically from the existing selector registry (hooks/selectors/registry.ts). Selectors are always paired with a manual fallback input using the canonical pair pattern — a selector field (basic mode) and a short-input field (advanced mode) linked by canonicalParamId.

The user sees a toggle button (ArrowLeftRight) to switch between the selector dropdown and manual text input. On submit, the modal resolves each canonical pair to the active mode's value, keyed by canonicalParamId.

Rules

  1. Every selector field MUST have a canonical pair — a corresponding short-input (or dropdown) field with the same canonicalParamId and mode: 'advanced'.
  2. required must be set identically on both fields in a pair. If the selector is required, the manual input must also be required.
  3. canonicalParamId must match the key the connector expects in sourceConfig (e.g. baseId, channel, teamId). The advanced field's id should typically match canonicalParamId.
  4. dependsOn references the selector field's id, not the canonicalParamId. The modal propagates dependency clearing across canonical siblings automatically — changing either field in a parent pair clears dependent children.

Selector canonical pair example (Airtable base → table cascade)

configFields: [
  // Base: selector (basic) + manual (advanced)
  {
    id: 'baseSelector',
    title: 'Base',
    type: 'selector',
    selectorKey: 'airtable.bases',     // Must exist in hooks/selectors/registry.ts
    canonicalParamId: 'baseId',
    mode: 'basic',
    placeholder: 'Select a base',
    required: true,
  },
  {
    id: 'baseId',
    title: 'Base ID',
    type: 'short-input',
    canonicalParamId: 'baseId',
    mode: 'advanced',
    placeholder: 'e.g. appXXXXXXXXXXXXXX',
    required: true,
  },
  // Table: selector depends on base (basic) + manual (advanced)
  {
    id: 'tableSelector',
    title: 'Table',
    type: 'selector',
    selectorKey: 'airtable.tables',
    canonicalParamId: 'tableIdOrName',
    mode: 'basic',
    dependsOn: ['baseSelector'],       // References the selector field ID
    placeholder: 'Select a table',
    required: true,
  },
  {
    id: 'tableIdOrName',
    title: 'Table Name or ID',
    type: 'short-input',
    canonicalParamId: 'tableIdOrName',
    mode: 'advanced',
    placeholder: 'e.g. Tasks',
    required: true,
  },
  // Non-selector fields stay as-is
  { id: 'maxRecords', title: 'Max Records', type: 'short-input', ... },
]

Selector with domain dependency (Jira/Confluence pattern)

When a selector depends on a plain short-input field (no canonical pair), dependsOn references that field's id directly. The domain field's value maps to SelectorContext.domain automatically via SELECTOR_CONTEXT_FIELDS.

configFields: [
  {
    id: 'domain',
    title: 'Jira Domain',
    type: 'short-input',
    placeholder: 'yoursite.atlassian.net',
    required: true,
  },
  {
    id: 'projectSelector',
    title: 'Project',
    type: 'selector',
    selectorKey: 'jira.projects',
    canonicalParamId: 'projectKey',
    mode: 'basic',
    dependsOn: ['domain'],
    placeholder: 'Select a project',
    required: true,
  },
  {
    id: 'projectKey',
    title: 'Project Key',
    type: 'short-input',
    canonicalParamId: 'projectKey',
    mode: 'advanced',
    placeholder: 'e.g. ENG, PROJ',
    required: true,
  },
]

How dependsOn maps to SelectorContext

The connector selector field builds a SelectorContext from dependency values. For the mapping to work, each dependency's canonicalParamId (or field id for non-canonical fields) must exist in SELECTOR_CONTEXT_FIELDS (lib/workflows/subblocks/context.ts):

oauthCredential, domain, teamId, projectId, knowledgeBaseId, planId,
siteId, collectionId, spreadsheetId, fileId, baseId, datasetId, serviceDeskId

Available selector keys

Check hooks/selectors/types.ts for the full SelectorKey union. Common ones for connectors:

SelectorKey Context Deps Returns
airtable.bases credential Base ID + name
airtable.tables credential, baseId Table ID + name
slack.channels credential Channel ID + name
gmail.labels credential Label ID + name
google.calendar credential Calendar ID + name
linear.teams credential Team ID + name
linear.projects credential, teamId Project ID + name
jira.projects credential, domain Project key + name
confluence.spaces credential, domain Space key + name
notion.databases credential Database ID + name
asana.workspaces credential Workspace GID + name
microsoft.teams credential Team ID + name
microsoft.channels credential, teamId Channel ID + name
webflow.sites credential Site ID + name
outlook.folders credential Folder ID + name

ExternalDocument Shape

Every document returned from listDocuments/getDocument must include:

{
  externalId: string          // Source-specific unique ID
  title: string               // Document title
  content: string             // Extracted plain text (or '' if contentDeferred)
  contentDeferred?: boolean   // true = content will be fetched via getDocument
  mimeType: 'text/plain'     // Always text/plain (content is extracted)
  contentHash: string         // Metadata-based hash for change detection
  sourceUrl?: string          // Link back to original (stored on document record)
  metadata?: Record<string, unknown>  // Source-specific data (fed to mapTags)
}

Content Deferral (Required for file/content-download connectors)

All connectors that require per-document API calls to fetch content MUST use contentDeferred: true. This is the standard pattern — listDocuments returns lightweight metadata stubs, and content is fetched lazily by the sync engine via getDocument only for new/changed documents.

This pattern is critical for reliability: the sync engine processes documents in batches and enqueues each batch for processing immediately. If a sync times out, all previously-batched documents are already queued. Without deferral, content downloads during listing can exhaust the sync task's time budget before any documents are saved.

When to use contentDeferred: true

  • The service's list API does NOT return document content (only metadata)
  • Content requires a separate download/export API call per document
  • Examples: Google Drive, OneDrive, SharePoint, Dropbox, Notion, Confluence, Gmail, Obsidian, Evernote, GitHub

When NOT to use contentDeferred

  • The list API already returns the full content inline (e.g., Slack messages, Reddit posts, HubSpot notes)
  • No per-document API call is needed to get content

Content Hash Strategy

Use a metadata-based contentHash — never a content-based hash. The hash must be derivable from the list response metadata alone, so the sync engine can detect changes without downloading content.

Good metadata hash sources:

  • modifiedTime / lastModifiedDateTime — changes when file is edited
  • Git blob SHA — unique per content version
  • API-provided content hash (e.g., Dropbox content_hash)
  • Version number (e.g., Confluence page version)

Format: {service}:{id}:{changeIndicator}

// Google Drive: modifiedTime changes on edit
contentHash: `gdrive:${file.id}:${file.modifiedTime ?? ''}`

// GitHub: blob SHA is a content-addressable hash
contentHash: `gitsha:${item.sha}`

// Dropbox: API provides content_hash
contentHash: `dropbox:${entry.id}:${entry.content_hash ?? entry.server_modified}`

// Confluence: version number increments on edit
contentHash: `confluence:${page.id}:${page.version.number}`

Critical invariant: The contentHash MUST be identical whether produced by listDocuments (stub) or getDocument (full doc). Both should use the same stub function to guarantee this.

Implementation Pattern

// 1. Create a stub function (sync, no API calls)
function fileToStub(file: ServiceFile): ExternalDocument {
  return {
    externalId: file.id,
    title: file.name || 'Untitled',
    content: '',
    contentDeferred: true,
    mimeType: 'text/plain',
    sourceUrl: `https://service.com/file/${file.id}`,
    contentHash: `service:${file.id}:${file.modifiedTime ?? ''}`,
    metadata: { /* fields needed by mapTags */ },
  }
}

// 2. listDocuments returns stubs (fast, metadata only)
listDocuments: async (accessToken, sourceConfig, cursor) => {
  const response = await fetchWithRetry(listUrl, { ... })
  const files = (await response.json()).files
  const documents = files.map(fileToStub)
  return { documents, nextCursor, hasMore }
}

// 3. getDocument fetches content and returns full doc with SAME contentHash
getDocument: async (accessToken, sourceConfig, externalId) => {
  const metadata = await fetchWithRetry(metadataUrl, { ... })
  const file = await metadata.json()
  if (file.trashed) return null

  try {
    const content = await fetchContent(accessToken, file)
    if (!content.trim()) return null
    const stub = fileToStub(file)
    return { ...stub, content, contentDeferred: false }
  } catch (error) {
    logger.warn(`Failed to fetch content for: ${file.name}`, { error })
    return null
  }
}

Reference Implementations

  • Google Drive: connectors/google-drive/google-drive.ts — file download/export with modifiedTime hash
  • GitHub: connectors/github/github.ts — git blob SHA hash
  • Notion: connectors/notion/notion.ts — blocks API with last_edited_time hash
  • Confluence: connectors/confluence/confluence.ts — version number hash

tagDefinitions — Declared Tag Definitions

Declare which tags the connector populates using semantic IDs. Shown in the add-connector modal as opt-out checkboxes. On connector creation, slots are dynamically assigned via getNextAvailableSlot — connectors never hardcode slot names.

tagDefinitions: [
  { id: 'labels', displayName: 'Labels', fieldType: 'text' },
  { id: 'version', displayName: 'Version', fieldType: 'number' },
  { id: 'lastModified', displayName: 'Last Modified', fieldType: 'date' },
],

Each entry has:

  • id: Semantic key matching a key returned by mapTags (e.g. 'labels', 'version')
  • displayName: Human-readable name shown in the UI (e.g. "Labels", "Last Modified")
  • fieldType: 'text' | 'number' | 'date' | 'boolean' — determines which slot pool to draw from

Users can opt out of specific tags in the modal. Disabled IDs are stored in sourceConfig.disabledTagIds. The assigned mapping (semantic id → slot) is stored in sourceConfig.tagSlotMapping.

@/connectors/utils Helpers

Reuse these instead of inlining the same logic (the validator enforces them):

  • htmlToPlainText(html) — strip HTML to plain text before indexing ExternalDocument.content. Never index raw HTML.
  • computeContentHash(content) — stable content hash for change detection.
  • parseTagDate(value) — parse to a valid Date or undefined (guards Invalid Date). Use in mapTags for date fields.
  • joinTagArray(value) — validate an array and join to a comma-separated string, or undefined. Use in mapTags for array/label fields.
  • parseMultiValue(value) — normalize a value into a string[].

mapTags — Metadata to Semantic Keys

Maps source metadata to semantic tag keys. Required if tagDefinitions is set. The sync engine calls this automatically and translates semantic keys to actual DB slots using the tagSlotMapping stored on the connector.

Return keys must match the id values declared in tagDefinitions.

Use the @/connectors/utils helpers for the common transforms — don't hand-roll date/array validation:

import { joinTagArray, parseTagDate } from '@/connectors/utils'

mapTags: (metadata: Record<string, unknown>): Record<string, unknown> => {
  const result: Record<string, unknown> = {}

  // joinTagArray validates the array and joins to a comma-separated string (undefined if empty)
  const labels = joinTagArray(metadata.labels)
  if (labels) result.labels = labels

  // Validate numbers — guard against NaN
  if (metadata.version != null) {
    const num = Number(metadata.version)
    if (!Number.isNaN(num)) result.version = num
  }

  // parseTagDate returns a valid Date or undefined (guards against Invalid Date)
  const lastModified = parseTagDate(metadata.lastModified)
  if (lastModified) result.lastModified = lastModified

  return result
}

External API Calls — Use fetchWithRetry

All external API calls must use fetchWithRetry from @/lib/knowledge/documents/utils instead of raw fetch(). This provides exponential backoff with retries on 429/502/503/504 errors. It returns a standard Response — all .ok, .json(), .text() checks work unchanged.

For validateConfig (user-facing, called on save), pass VALIDATE_RETRY_OPTIONS to cap wait time at ~7s. Background operations (listDocuments, getDocument) use the built-in defaults (5 retries, ~31s max).

import { VALIDATE_RETRY_OPTIONS, fetchWithRetry } from '@/lib/knowledge/documents/utils'

// Background sync — use defaults
const response = await fetchWithRetry(url, {
  method: 'GET',
  headers: { Authorization: `Bearer ${accessToken}` },
})

// validateConfig — tighter retry budget
const response = await fetchWithRetry(url, { ... }, VALIDATE_RETRY_OPTIONS)

sourceUrl

If ExternalDocument.sourceUrl is set, the sync engine stores it on the document record. Always construct the full URL (not a relative path).

Capped or Incomplete Listings — syncContext.listingCapped (REQUIRED)

If listDocuments can ever return less than the full source set on a non-incremental sync — a maxItems/maxDocuments-style cap, or a transient per-item error that drops a still-existing document from the listing — it MUST set syncContext.listingCapped = true when that happens.

The sync engine reconciles deletions by comparing the full listing against stored documents: anything not seen is hard-deleted (sync-engine.ts, gated on !syncContext?.listingCapped). A truncated listing without this flag deletes every real document beyond the cap. This was the single most common bug found when auditing connectors — do not omit it.

if (hitLimit && syncContext) {
  syncContext.listingCapped = true
}

Rules:

  • Set it when a user-configured cap truncates the listing while more documents exist
  • Set it when a thrown error caused a still-present document to be skipped during listing
  • Do NOT set it when the source is genuinely exhausted (deleted documents must still reconcile)
  • Do NOT set it for intentional scope filters (e.g. a date cutoff) — out-of-scope documents should be reconciled normally

Sync Engine Behavior (Do Not Modify)

The sync engine (lib/knowledge/connectors/sync-engine.ts) is connector-agnostic. It:

  1. Calls listDocuments with pagination until hasMore is false
  2. Compares contentHash to detect new/changed/unchanged documents
  3. Stores sourceUrl and calls mapTags on insert/update automatically
  4. Handles soft-delete of removed documents
  5. Resolves access tokens automatically — OAuth tokens are refreshed, API keys are decrypted from the encryptedApiKey column

You never need to modify the sync engine when adding a connector.

Icon

The icon field on ConnectorConfig is used throughout the UI — in the connector list, the add-connector modal, and as the document icon in the knowledge base table (replacing the generic file type icon for connector-sourced documents). The icon is read from CONNECTOR_REGISTRY[connectorType].icon at runtime — no separate icon map to maintain.

If the service already has an icon in apps/sim/components/icons.tsx (from a tool integration), reuse it. Otherwise, ask the user to provide the SVG.

Registering

Register in BOTH registries, keeping the same alphabetical-by-id ordering in each.

  1. Server registryapps/sim/connectors/registry.server.ts (server-only full registry; holds full connectors with runtime functions, imported by the sync engine and knowledge API routes):
import { {service}Connector } from '@/connectors/{service}'

export const CONNECTOR_REGISTRY: ConnectorRegistry = {
  // ... existing connectors ...
  {service}: {service}Connector,
}
  1. Client-safe meta registryapps/sim/connectors/registry.ts (imports each connector's meta.ts only, so client components can use it without pulling server-only code; the metadata counterpart to BLOCK_META_REGISTRY):
import { {service}ConnectorMeta } from '@/connectors/{service}/meta'

export const CONNECTOR_META_REGISTRY: ConnectorMetaRegistry = {
  // ... existing connector metas ...
  {service}: {service}ConnectorMeta,
}

registry.ts exports CONNECTOR_META_REGISTRY: ConnectorMetaRegistry plus the helpers getConnectorMeta(id) and getAllConnectorMeta(), importing each @/connectors/{service}/meta directly — never the runtime module. registry.server.ts exports CONNECTOR_REGISTRY: ConnectorRegistry.

Reference Implementations

  • OAuth + contentDeferred: apps/sim/connectors/google-drive/google-drive.ts — file download with metadata-based hash, orderBy for deterministic pagination
  • OAuth + contentDeferred (blocks API): apps/sim/connectors/notion/notion.ts — complex block content extraction deferred to getDocument
  • OAuth + contentDeferred (git): apps/sim/connectors/github/github.ts — blob SHA hash, tree listing
  • OAuth + inline content: apps/sim/connectors/confluence/confluence.ts — multiple config field types, mapTags, label fetching
  • API key: apps/sim/connectors/fireflies/fireflies.ts — GraphQL API with Bearer token auth

Checklist

  • Created connectors/{service}/meta.ts with {service}ConnectorMeta: ConnectorMeta (icon, name, auth, configFields, tagDefinitions) — no server/runtime imports
  • Created connectors/{service}/{service}.ts with {service}Connector: ConnectorConfig spreading the meta + runtime functions
  • Created connectors/{service}/index.ts barrel export
  • Auth configured correctly:
    • OAuth: auth.provider matches an existing OAuthService in lib/oauth/types.ts
    • API key: auth.label and auth.placeholder set appropriately
  • Selector fields configured correctly (if applicable):
    • Every type: 'selector' field has a canonical pair (short-input or dropdown with same canonicalParamId and mode: 'advanced')
    • required is identical on both fields in each canonical pair
    • selectorKey exists in hooks/selectors/registry.ts
    • dependsOn references selector field IDs (not canonicalParamId)
    • Dependency canonicalParamId values exist in SELECTOR_CONTEXT_FIELDS
  • listDocuments handles pagination with metadata-based content hashes
  • syncContext.listingCapped = true set whenever the listing is truncated (max-items cap or transient per-item error) — required to prevent the engine's deletion reconciliation from removing unseen documents
  • contentDeferred: true used if content requires per-doc API calls (file download, export, blocks fetch)
  • contentHash is metadata-based (not content-based) and identical between stub and getDocument
  • sourceUrl set on each ExternalDocument (full URL, not relative)
  • metadata includes source-specific data for tag mapping
  • tagDefinitions declared for each semantic key returned by mapTags
  • mapTags implemented if source has useful metadata (labels, dates, versions)
  • validateConfig verifies the source is accessible
  • All external API calls use fetchWithRetry (not raw fetch)
  • All optional config fields validated in validateConfig
  • Icon exists in components/icons.tsx (or asked user to provide SVG)
  • Registered the full connector in connectors/registry.server.ts
  • Registered the meta in connectors/registry.ts (same alphabetical-by-id ordering as registry.server.ts)