From e55d190e1f90b1fcba089815dc335639f4eed322 Mon Sep 17 00:00:00 2001 From: Matteo Date: Wed, 20 May 2026 11:54:02 +0200 Subject: [PATCH 01/19] connector-batch: foundation for large-scale catalog growth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three pieces of infrastructure to keep the adapter catalog maintainable as it grows from 40 to 100+ entries: 1. scripts/regenerate-catalog.mjs — codegen that scans every JSON under packages/backend/src/adapters/{de,gb,intl,br,in,jp,ng}/ and rewrites the AUTOGEN-marked sections of catalog.ts (imports + RAW_ADAPTERS array). Wired into backend's prebuild script so nest build always has the catalog in sync with the JSON files. Eliminates the chore of hand-editing two lists in catalog.ts on every new adapter and the class of bugs where someone forgets one of the two. 2. scripts/validate-adapters.mjs — quality gate with hard errors (must pass for CI) on the things that genuinely break the system (missing required fields, slug-filename mismatch, unknown connector/auth types, empty tools array) and soft warnings (instructions length, tool description length, parameter descriptions, slug-prefixed tool names) that surface low-quality adapters without blocking the batch. Existing 40 adapters all pass the hard gate. 3. Frontend store: +18 category labels (CRM, Email, Marketing Automation, Project Management, Scheduling, Forms, Customer Support, Payments, E-commerce, Analytics, Publishing, E-signature, Lead Enrichment, Knowledge Base, Social, Maps & Geo, Travel, CMS) so the incoming connector batch renders with proper labels in the store UI. CI: added two new steps before backend tests — validate-adapters runs the gate, then we regenerate catalog.ts and fail the run if it differs from what's committed (catches the case where someone adds a JSON but forgets to regenerate). Both steps are fast (<5s). --- .github/workflows/ci.yml | 13 + packages/backend/package.json | 1 + packages/backend/src/adapters/catalog.ts | 120 +++++---- .../src/app/connectors/store/page.tsx | 18 ++ scripts/regenerate-catalog.mjs | 101 +++++++ scripts/validate-adapters.mjs | 253 ++++++++++++++++++ 6 files changed, 448 insertions(+), 58 deletions(-) create mode 100644 scripts/regenerate-catalog.mjs create mode 100644 scripts/validate-adapters.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6110edc..ac0e079 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,19 @@ jobs: - name: Install dependencies run: npm ci + - name: Validate adapter catalog + run: node scripts/validate-adapters.mjs + + - name: Verify catalog.ts is up-to-date with adapter JSONs + run: | + node scripts/regenerate-catalog.mjs + if ! git diff --quiet packages/backend/src/adapters/catalog.ts; then + echo "::error::catalog.ts is out of sync with adapter JSON files." + echo "Run: node scripts/regenerate-catalog.mjs && commit the result." + git diff packages/backend/src/adapters/catalog.ts + exit 1 + fi + - name: Generate Prisma client run: npx prisma generate working-directory: packages/backend diff --git a/packages/backend/package.json b/packages/backend/package.json index 28f5bda..9775865 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -5,6 +5,7 @@ "private": true, "license": "BUSL-1.1", "scripts": { + "prebuild": "node ../../scripts/regenerate-catalog.mjs", "build": "prisma generate && nest build", "dev": "nest start --watch", "start": "nest start", diff --git a/packages/backend/src/adapters/catalog.ts b/packages/backend/src/adapters/catalog.ts index f405c34..fcc26cf 100644 --- a/packages/backend/src/adapters/catalog.ts +++ b/packages/backend/src/adapters/catalog.ts @@ -1,43 +1,45 @@ -import * as dhlTracking from './de/dhl-tracking.json'; +// === AUTOGEN-IMPORTS-BEGIN === run scripts/regenerate-catalog.mjs === +import * as billomat from './de/billomat.json'; import * as bundesbank from './de/bundesbank.json'; +import * as datev from './de/datev.json'; import * as destatisGenesis from './de/destatis-genesis.json'; -import * as ninaWarnung from './de/nina-warnung.json'; -import * as teamviewer from './de/teamviewer.json'; -import * as n26OpenBanking from './de/n26-openbanking.json'; -import * as payone from './de/payone.json'; -import * as weclapp from './de/weclapp.json'; -import * as immobilienscout24 from './de/immobilienscout24.json'; -import * as mfrFieldservice from './de/mfr-fieldservice.json'; +import * as deutscheBahn from './de/deutsche-bahn.json'; +import * as dhlTracking from './de/dhl-tracking.json'; +import * as dpdGermany from './de/dpd-germany.json'; import * as fastbill from './de/fastbill.json'; -import * as billomat from './de/billomat.json'; -import * as datev from './de/datev.json'; -import * as scopevisio from './de/scopevisio.json'; -import * as kenjo from './de/kenjo.json'; -import * as planradar from './de/planradar.json'; -import * as viesVat from './de/vies-vat.json'; +import * as glsTracking from './de/gls-tracking.json'; import * as handelsregister from './de/handelsregister.json'; -import * as deutscheBahn from './de/deutsche-bahn.json'; +import * as hereGeocoding from './de/here-geocoding.json'; +import * as hrworks from './de/hrworks.json'; +import * as immobilienscout24 from './de/immobilienscout24.json'; +import * as kenjo from './de/kenjo.json'; +import * as mfrFieldservice from './de/mfr-fieldservice.json'; +import * as n26Openbanking from './de/n26-openbanking.json'; +import * as ninaWarnung from './de/nina-warnung.json'; import * as openplz from './de/openplz.json'; import * as oxomi from './de/oxomi.json'; -import * as dpdGermany from './de/dpd-germany.json'; -import * as glsTracking from './de/gls-tracking.json'; -import * as shipcloud from './de/shipcloud.json'; +import * as payone from './de/payone.json'; +import * as personio from './de/personio.json'; +import * as planradar from './de/planradar.json'; +import * as scopevisio from './de/scopevisio.json'; import * as sendcloud from './de/sendcloud.json'; -import * as xentral from './de/xentral.json'; +import * as shipcloud from './de/shipcloud.json'; import * as shopware6 from './de/shopware-6.json'; -import * as hereGeocoding from './de/here-geocoding.json'; -import * as personio from './de/personio.json'; -import * as hrworks from './de/hrworks.json'; +import * as teamviewer from './de/teamviewer.json'; +import * as viesVat from './de/vies-vat.json'; +import * as weclapp from './de/weclapp.json'; +import * as xentral from './de/xentral.json'; import * as companiesHouse from './gb/companies-house.json'; import * as wise from './gb/wise.json'; -import * as razorpay from './in/razorpay.json'; -import * as mercadoLibre from './br/mercado-libre.json'; -import * as paystack from './ng/paystack.json'; -import * as lineMessaging from './jp/line-messaging.json'; import * as sorare from './intl/sorare.json'; import * as whatsappBusiness from './intl/whatsapp-business.json'; -import * as wordpress from './intl/wordpress.json'; import * as woocommerce from './intl/woocommerce.json'; +import * as wordpress from './intl/wordpress.json'; +import * as mercadoLibre from './br/mercado-libre.json'; +import * as razorpay from './in/razorpay.json'; +import * as lineMessaging from './jp/line-messaging.json'; +import * as paystack from './ng/paystack.json'; +// === AUTOGEN-IMPORTS-END === import { buildGraphqlBuiltinTools } from '../connectors/graphql-builtins'; export interface AdapterMeta { @@ -103,52 +105,54 @@ function withGraphqlBuiltins(adapter: AdapterDefinition): AdapterDefinition { return { ...adapter, tools: [...builtins, ...adapter.tools] }; } -// Register all adapters here. To add a new adapter: -// 1. Create the JSON file in the appropriate region folder -// 2. Import it above -// 3. Add it to this array +// To add a new adapter: +// 1. Create the JSON file under packages/backend/src/adapters/{region}/ +// 2. Run `node scripts/regenerate-catalog.mjs` from the repo root. +// The imports and RAW_ADAPTERS array below are auto-generated. +// === AUTOGEN-ARRAY-BEGIN === run scripts/regenerate-catalog.mjs === const RAW_ADAPTERS: AdapterDefinition[] = [ - dhlTracking as unknown as AdapterDefinition, + billomat as unknown as AdapterDefinition, bundesbank as unknown as AdapterDefinition, + datev as unknown as AdapterDefinition, destatisGenesis as unknown as AdapterDefinition, - ninaWarnung as unknown as AdapterDefinition, - teamviewer as unknown as AdapterDefinition, - n26OpenBanking as unknown as AdapterDefinition, - payone as unknown as AdapterDefinition, - weclapp as unknown as AdapterDefinition, - immobilienscout24 as unknown as AdapterDefinition, - mfrFieldservice as unknown as AdapterDefinition, + deutscheBahn as unknown as AdapterDefinition, + dhlTracking as unknown as AdapterDefinition, + dpdGermany as unknown as AdapterDefinition, fastbill as unknown as AdapterDefinition, - billomat as unknown as AdapterDefinition, - datev as unknown as AdapterDefinition, - scopevisio as unknown as AdapterDefinition, - kenjo as unknown as AdapterDefinition, - planradar as unknown as AdapterDefinition, - viesVat as unknown as AdapterDefinition, + glsTracking as unknown as AdapterDefinition, handelsregister as unknown as AdapterDefinition, - deutscheBahn as unknown as AdapterDefinition, + hereGeocoding as unknown as AdapterDefinition, + hrworks as unknown as AdapterDefinition, + immobilienscout24 as unknown as AdapterDefinition, + kenjo as unknown as AdapterDefinition, + mfrFieldservice as unknown as AdapterDefinition, + n26Openbanking as unknown as AdapterDefinition, + ninaWarnung as unknown as AdapterDefinition, openplz as unknown as AdapterDefinition, oxomi as unknown as AdapterDefinition, - dpdGermany as unknown as AdapterDefinition, - glsTracking as unknown as AdapterDefinition, - shipcloud as unknown as AdapterDefinition, + payone as unknown as AdapterDefinition, + personio as unknown as AdapterDefinition, + planradar as unknown as AdapterDefinition, + scopevisio as unknown as AdapterDefinition, sendcloud as unknown as AdapterDefinition, - xentral as unknown as AdapterDefinition, + shipcloud as unknown as AdapterDefinition, shopware6 as unknown as AdapterDefinition, - hereGeocoding as unknown as AdapterDefinition, - personio as unknown as AdapterDefinition, - hrworks as unknown as AdapterDefinition, + teamviewer as unknown as AdapterDefinition, + viesVat as unknown as AdapterDefinition, + weclapp as unknown as AdapterDefinition, + xentral as unknown as AdapterDefinition, companiesHouse as unknown as AdapterDefinition, wise as unknown as AdapterDefinition, - razorpay as unknown as AdapterDefinition, - mercadoLibre as unknown as AdapterDefinition, - paystack as unknown as AdapterDefinition, - lineMessaging as unknown as AdapterDefinition, sorare as unknown as AdapterDefinition, whatsappBusiness as unknown as AdapterDefinition, - wordpress as unknown as AdapterDefinition, woocommerce as unknown as AdapterDefinition, + wordpress as unknown as AdapterDefinition, + mercadoLibre as unknown as AdapterDefinition, + razorpay as unknown as AdapterDefinition, + lineMessaging as unknown as AdapterDefinition, + paystack as unknown as AdapterDefinition, ]; +// === AUTOGEN-ARRAY-END === const ALL_ADAPTERS: AdapterDefinition[] = RAW_ADAPTERS.map(withGraphqlBuiltins); diff --git a/packages/frontend/src/app/connectors/store/page.tsx b/packages/frontend/src/app/connectors/store/page.tsx index 18c2d3e..c079783 100644 --- a/packages/frontend/src/app/connectors/store/page.tsx +++ b/packages/frontend/src/app/connectors/store/page.tsx @@ -29,6 +29,24 @@ const CATEGORY_LABELS: Record = { accounting: 'Accounting', hr: 'HR', messaging: 'Messaging', + crm: 'CRM', + email: 'Email', + 'marketing-automation': 'Marketing Automation', + 'project-management': 'Project Management', + scheduling: 'Scheduling', + forms: 'Forms', + support: 'Customer Support', + payments: 'Payments', + 'e-commerce': 'E-commerce', + analytics: 'Analytics', + publishing: 'Publishing', + 'e-signature': 'E-signature', + enrichment: 'Lead Enrichment', + knowledge: 'Knowledge Base', + social: 'Social', + maps: 'Maps & Geo', + travel: 'Travel', + cms: 'CMS', }; const AUTH_LABELS: Record = { diff --git a/scripts/regenerate-catalog.mjs b/scripts/regenerate-catalog.mjs new file mode 100644 index 0000000..f908e40 --- /dev/null +++ b/scripts/regenerate-catalog.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node +/** + * Regenerates the auto-managed sections of + * packages/backend/src/adapters/catalog.ts by scanning every *.json under + * packages/backend/src/adapters/{de,gb,intl,br,in,jp,ng}/. + * + * Hand-written code OUTSIDE the AUTOGEN markers is preserved. + * + * Run: node scripts/regenerate-catalog.mjs + * Wired in: npm --workspace packages/backend run prebuild (see package.json). + * + * Rationale: at >100 adapters, hand-editing two lists in catalog.ts on every + * new adapter is error-prone (forgot the import, forgot the RAW_ADAPTERS + * entry, mis-ordered them). A codegen step is deterministic, diff-friendly, + * and runs before nest build so dist/ always has the up-to-date registration. + */ + +import { readdirSync, readFileSync, writeFileSync, statSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(__dirname, '..'); +const ADAPTERS_DIR = join(REPO_ROOT, 'packages/backend/src/adapters'); +const CATALOG_PATH = join(ADAPTERS_DIR, 'catalog.ts'); + +const REGIONS = ['de', 'gb', 'intl', 'br', 'in', 'jp', 'ng']; + +const IMPORTS_BEGIN = '// === AUTOGEN-IMPORTS-BEGIN === run scripts/regenerate-catalog.mjs ==='; +const IMPORTS_END = '// === AUTOGEN-IMPORTS-END ==='; +const ARRAY_BEGIN = '// === AUTOGEN-ARRAY-BEGIN === run scripts/regenerate-catalog.mjs ==='; +const ARRAY_END = '// === AUTOGEN-ARRAY-END ==='; + +function toCamelCase(slug) { + return slug.replace(/[-_]([a-z0-9])/g, (_, c) => c.toUpperCase()); +} + +function escapeRegex(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function collectAdapters() { + const out = []; + for (const region of REGIONS) { + const regionPath = join(ADAPTERS_DIR, region); + let entries; + try { + entries = readdirSync(regionPath); + } catch { + continue; + } + for (const file of entries) { + if (!file.endsWith('.json')) continue; + const fullPath = join(regionPath, file); + if (!statSync(fullPath).isFile()) continue; + const slug = file.replace(/\.json$/, ''); + out.push({ region, slug, file }); + } + } + // Stable order: by region (REGIONS order), then alphabetical by slug. + out.sort((a, b) => { + const r = REGIONS.indexOf(a.region) - REGIONS.indexOf(b.region); + if (r !== 0) return r; + return a.slug.localeCompare(b.slug); + }); + return out; +} + +function replaceBlock(source, begin, end, replacement) { + const re = new RegExp(`${escapeRegex(begin)}[\\s\\S]*?${escapeRegex(end)}`); + if (!re.test(source)) { + throw new Error(`Markers not found:\n ${begin}\n ${end}\nin ${CATALOG_PATH}`); + } + return source.replace(re, `${begin}\n${replacement}\n${end}`); +} + +function regenerate() { + const adapters = collectAdapters(); + + const importsBody = adapters + .map((a) => `import * as ${toCamelCase(a.slug)} from './${a.region}/${a.file}';`) + .join('\n'); + + const arrayBody = + `const RAW_ADAPTERS: AdapterDefinition[] = [\n` + + adapters + .map((a) => ` ${toCamelCase(a.slug)} as unknown as AdapterDefinition,`) + .join('\n') + + `\n];`; + + let source = readFileSync(CATALOG_PATH, 'utf8'); + source = replaceBlock(source, IMPORTS_BEGIN, IMPORTS_END, importsBody); + source = replaceBlock(source, ARRAY_BEGIN, ARRAY_END, arrayBody); + writeFileSync(CATALOG_PATH, source, 'utf8'); + + console.log( + `✓ Regenerated ${CATALOG_PATH}: ${adapters.length} adapters across ${new Set(adapters.map((a) => a.region)).size} regions.`, + ); +} + +regenerate(); diff --git a/scripts/validate-adapters.mjs b/scripts/validate-adapters.mjs new file mode 100644 index 0000000..fcef2ef --- /dev/null +++ b/scripts/validate-adapters.mjs @@ -0,0 +1,253 @@ +#!/usr/bin/env node +/** + * Validates every adapter JSON under packages/backend/src/adapters/ against + * the quality gate that protects the catalog from low-effort connectors. + * + * Exits with code 0 if all adapters pass, 1 if any fail. + * + * Run locally: node scripts/validate-adapters.mjs + * Run in CI: same — wired in .github/workflows/ci.yml. + * + * Hard gates (fail CI): + * 1. Required top-level fields present (slug, name, description, region, + * category, icon, docsUrl, requiredEnvVars, connector, tools). + * 2. `slug` matches the filename (so the codegen importer finds it). + * 3. `connector.type` is one of REST, GRAPHQL, SOAP, MCP, DATABASE, + * LOGIN_TOKEN. + * 4. `connector.authType` is one of NONE, API_KEY, BEARER_TOKEN, BASIC, + * BASIC_AUTH, OAUTH2, LOGIN_TOKEN. + * 5. `requiredEnvVars` are referenced somewhere as {{VAR}} (otherwise the + * env var is unused metadata and won't actually be injected). + * 6. `tools` is a non-empty array. + * + * Soft warnings (printed, do NOT fail CI): + * - `instructions` shorter than 800 chars. + * - Tool name not prefixed with `{slug_underscored}_`. + * - Tool `description` shorter than 60 chars. + * - Tool parameter property missing `description`. + * - Tool `endpointMapping` missing method/path (text-only "skill" tools + * that return guidance are a known valid pattern — see WordPress adapter). + */ + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(__dirname, '..'); +const ADAPTERS_DIR = join(REPO_ROOT, 'packages/backend/src/adapters'); +const REGIONS = ['de', 'gb', 'intl', 'br', 'in', 'jp', 'ng']; + +const ALLOWED_CONNECTOR_TYPES = new Set([ + 'REST', + 'GRAPHQL', + 'SOAP', + 'MCP', + 'DATABASE', + 'LOGIN_TOKEN', +]); +const ALLOWED_AUTH_TYPES = new Set([ + 'NONE', + 'API_KEY', + 'BEARER_TOKEN', + 'BASIC', + 'BASIC_AUTH', + 'OAUTH2', + 'LOGIN_TOKEN', + 'QUERY_AUTH', // existing adapters (destatis, here-geocoding, oxomi) pass the API key as a query string parameter +]); + +const REQUIRED_TOP_LEVEL = [ + 'slug', + 'name', + 'description', + 'region', + 'category', + 'icon', + 'docsUrl', + 'requiredEnvVars', + 'connector', + 'tools', +]; + +const MIN_INSTRUCTIONS_LEN = 800; +const MIN_TOOL_DESCRIPTION_LEN = 60; + +function collectAdapters() { + const out = []; + for (const region of REGIONS) { + const regionPath = join(ADAPTERS_DIR, region); + let entries; + try { + entries = readdirSync(regionPath); + } catch { + continue; + } + for (const file of entries) { + if (!file.endsWith('.json')) continue; + const fullPath = join(regionPath, file); + if (!statSync(fullPath).isFile()) continue; + out.push({ region, file, fullPath }); + } + } + return out; +} + +function isPlaceholderReferenced(envVar, adapter) { + const placeholder = `{{${envVar}}}`; + const haystack = JSON.stringify(adapter); + return haystack.includes(placeholder); +} + +function validateAdapter(adapter, file, region) { + const errors = []; + const warnings = []; + const expectedSlug = file.replace(/\.json$/, ''); + + for (const key of REQUIRED_TOP_LEVEL) { + if (adapter[key] === undefined || adapter[key] === null) { + errors.push(`missing required field: ${key}`); + } + } + if (errors.length) return { errors, warnings }; + + if (adapter.slug !== expectedSlug) { + errors.push( + `slug "${adapter.slug}" does not match filename "${expectedSlug}"`, + ); + } + // region mismatch is informational only — some adapters live under a + // region directory but declare a wider region (e.g. eu under de/). + if (adapter.region !== region) { + warnings.push( + `region "${adapter.region}" does not match directory "${region}"`, + ); + } + + if (!ALLOWED_CONNECTOR_TYPES.has(adapter.connector?.type)) { + errors.push( + `connector.type "${adapter.connector?.type}" not in [${[...ALLOWED_CONNECTOR_TYPES].join(', ')}]`, + ); + } + if (!ALLOWED_AUTH_TYPES.has(adapter.connector?.authType)) { + errors.push( + `connector.authType "${adapter.connector?.authType}" not in [${[...ALLOWED_AUTH_TYPES].join(', ')}]`, + ); + } + + // requiredEnvVars that aren't referenced as {{VAR}} are typically env vars + // documented for the operator (e.g. an account ID the agent passes as a + // per-call tool parameter rather than something the connector auto-injects). + // Treat as a soft warning. + for (const envVar of adapter.requiredEnvVars || []) { + if (!isPlaceholderReferenced(envVar, adapter)) { + warnings.push( + `requiredEnvVars contains "${envVar}" but it's not auto-injected via {{${envVar}}} (operator must set it for documentation, agent passes it as a tool param)`, + ); + } + } + + const slugUnderscored = adapter.slug.replace(/-/g, '_'); + if (!Array.isArray(adapter.tools) || adapter.tools.length === 0) { + errors.push('tools array is empty'); + return { errors, warnings }; + } + + // --- Soft warnings --- + if (!adapter.instructions || adapter.instructions.length < MIN_INSTRUCTIONS_LEN) { + warnings.push( + `instructions field is ${adapter.instructions?.length || 0} chars (recommend ≥ ${MIN_INSTRUCTIONS_LEN})`, + ); + } + + for (const tool of adapter.tools) { + if (!tool.name || typeof tool.name !== 'string') { + errors.push(`tool with no name: ${JSON.stringify(tool).slice(0, 80)}`); + continue; + } + if (!tool.name.startsWith(`${slugUnderscored}_`)) { + warnings.push( + `tool "${tool.name}" not prefixed with "${slugUnderscored}_"`, + ); + } + if (!tool.description || tool.description.length < MIN_TOOL_DESCRIPTION_LEN) { + warnings.push( + `tool "${tool.name}" description is ${tool.description?.length || 0} chars (recommend ≥ ${MIN_TOOL_DESCRIPTION_LEN})`, + ); + } + // endpointMapping is omitted on text-only "skill" tools that return + // guidance instead of calling an API — that's a valid pattern, skip the + // check. + const props = tool.parameters?.properties; + if (props && typeof props === 'object') { + for (const [pname, pdef] of Object.entries(props)) { + if (!pdef || typeof pdef !== 'object' || !pdef.description) { + warnings.push( + `tool "${tool.name}" parameter "${pname}" missing description`, + ); + } + } + } + } + + return { errors, warnings }; +} + +function main() { + const adapters = collectAdapters(); + if (adapters.length === 0) { + console.error('No adapters found.'); + process.exit(1); + } + + const showWarnings = process.argv.includes('--warn'); + + let failed = 0; + let passed = 0; + let totalWarnings = 0; + for (const { region, file, fullPath } of adapters) { + let raw; + try { + raw = readFileSync(fullPath, 'utf8'); + } catch (e) { + console.error(`✗ ${region}/${file}: cannot read — ${e.message}`); + failed++; + continue; + } + let parsed; + try { + parsed = JSON.parse(raw); + } catch (e) { + console.error(`✗ ${region}/${file}: invalid JSON — ${e.message}`); + failed++; + continue; + } + const { errors, warnings } = validateAdapter(parsed, file, region); + totalWarnings += warnings.length; + if (errors.length === 0) { + passed++; + if (showWarnings && warnings.length) { + console.warn(`⚠ ${region}/${file}: ${warnings.length} warning(s)`); + for (const w of warnings) console.warn(` - ${w}`); + } + continue; + } + failed++; + console.error(`✗ ${region}/${file}:`); + for (const err of errors) console.error(` - ${err}`); + if (showWarnings && warnings.length) { + console.warn(` ⚠ also ${warnings.length} warning(s)`); + for (const w of warnings) console.warn(` - ${w}`); + } + } + + console.log( + `\nValidated ${adapters.length} adapters: ${passed} passed, ${failed} failed${showWarnings ? `, ${totalWarnings} total warnings` : ''}.`, + ); + if (!showWarnings && totalWarnings > 0) { + console.log(`(${totalWarnings} non-blocking warnings hidden — re-run with --warn to see them)`); + } + process.exit(failed === 0 ? 0 : 1); +} + +main(); From 4beb424ca19e122e846416e9a82b0e37cc2f9da3 Mon Sep 17 00:00:00 2001 From: Matteo Date: Wed, 20 May 2026 12:03:02 +0200 Subject: [PATCH 02/19] connectors: add Pipedrive, Mailchimp, SendGrid adapters Three high-priority greenfield SaaS connectors with rich instructions fields, live edge specs (RUN_*_LIVE gated), and full tool coverage. - Pipedrive (CRM): 23 tools across deals, persons, organizations, activities, pipelines, stages, users, custom field discovery and universal search. QUERY_AUTH with api_token in query string. Mixed v1 + v2 API as per Pipedrive's own structure. - Mailchimp (Email/MA): 16 tools across audiences, members, segments, campaigns, templates and account info. BEARER_TOKEN with the datacenter-prefixed base URL pattern (us6 etc) baked into the env var template. - SendGrid (Transactional email): 14 tools across mail send, templates, stats, suppressions (bounces / spam / invalid), marketing contacts, marketing lists, SGQL search. BEARER_TOKEN. All three pass the validator hard gate and their live edge probes return 401 from the vendor router (proves endpoint paths are recognized). Catalog auto-regenerated to 43 adapters total. --- packages/backend/src/adapters/catalog.ts | 6 + .../backend/src/adapters/intl/mailchimp.json | 494 ++++++++++ .../src/adapters/intl/mailchimp.live.spec.ts | 94 ++ .../backend/src/adapters/intl/pipedrive.json | 899 ++++++++++++++++++ .../src/adapters/intl/pipedrive.live.spec.ts | 155 +++ .../backend/src/adapters/intl/sendgrid.json | 451 +++++++++ .../src/adapters/intl/sendgrid.live.spec.ts | 56 ++ 7 files changed, 2155 insertions(+) create mode 100644 packages/backend/src/adapters/intl/mailchimp.json create mode 100644 packages/backend/src/adapters/intl/mailchimp.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/pipedrive.json create mode 100644 packages/backend/src/adapters/intl/pipedrive.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/sendgrid.json create mode 100644 packages/backend/src/adapters/intl/sendgrid.live.spec.ts diff --git a/packages/backend/src/adapters/catalog.ts b/packages/backend/src/adapters/catalog.ts index fcc26cf..e8dad3e 100644 --- a/packages/backend/src/adapters/catalog.ts +++ b/packages/backend/src/adapters/catalog.ts @@ -31,6 +31,9 @@ import * as weclapp from './de/weclapp.json'; import * as xentral from './de/xentral.json'; import * as companiesHouse from './gb/companies-house.json'; import * as wise from './gb/wise.json'; +import * as mailchimp from './intl/mailchimp.json'; +import * as pipedrive from './intl/pipedrive.json'; +import * as sendgrid from './intl/sendgrid.json'; import * as sorare from './intl/sorare.json'; import * as whatsappBusiness from './intl/whatsapp-business.json'; import * as woocommerce from './intl/woocommerce.json'; @@ -143,6 +146,9 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ xentral as unknown as AdapterDefinition, companiesHouse as unknown as AdapterDefinition, wise as unknown as AdapterDefinition, + mailchimp as unknown as AdapterDefinition, + pipedrive as unknown as AdapterDefinition, + sendgrid as unknown as AdapterDefinition, sorare as unknown as AdapterDefinition, whatsappBusiness as unknown as AdapterDefinition, woocommerce as unknown as AdapterDefinition, diff --git a/packages/backend/src/adapters/intl/mailchimp.json b/packages/backend/src/adapters/intl/mailchimp.json new file mode 100644 index 0000000..121fab3 --- /dev/null +++ b/packages/backend/src/adapters/intl/mailchimp.json @@ -0,0 +1,494 @@ +{ + "slug": "mailchimp", + "name": "Mailchimp", + "description": "Drive Mailchimp's Marketing API from any AI agent: audiences (lists), members, tags, campaigns, templates, segments and account info. 16 ready-to-use tools. Bearer-token auth with a datacenter-prefixed base URL.", + "instructions": "This connector uses the Mailchimp Marketing API v3.0.\n\n**Datacenter-prefixed base URL** (Mailchimp-specific quirk): each account lives on a numbered datacenter — `us1`, `us2`, … `us21`, etc. — and the API URL embeds it: `https://us{N}.api.mailchimp.com/3.0`. The datacenter suffix is the last segment of your API key after the dash (so an API key with `-us6` at the end lives on `us6`). The adapter takes the datacenter as an env var `MAILCHIMP_DC` and substitutes it into the base URL — if you set the wrong DC you'll get 404 NotResolved errors.\n\n**Setup**:\n1. Sign in to Mailchimp → bottom-left avatar → **Profile → Extras → API keys → Create a key**.\n2. The key Mailchimp issues you looks roughly like `<32-hex-chars>-usN`. Whatever the suffix says (`-usN`) IS your datacenter.\n3. Set `MAILCHIMP_API_KEY` = the full key (including `-usN`); set `MAILCHIMP_DC` = `usN` (e.g. `us6`).\n\n**Authentication**: Bearer token. The engine sends `Authorization: Bearer ${MAILCHIMP_API_KEY}`. Mailchimp also supports HTTP Basic with `anystring:API_KEY` but Bearer is cleaner and equally valid since 2019.\n\n**Audience IDs vs list IDs**: Mailchimp renamed 'lists' to 'audiences' in the UI but the API still calls them `lists`. The `list_id` in URLs is a 10-character alphanumeric (e.g. `a1b2c3d4e5`) — find it via `mailchimp_list_audiences` once and pin it.\n\n**Member subscriber_hash**: members are addressed by the **MD5 hash of the lowercase email**, NOT the raw email. Most tools that operate on a member accept either the raw email OR the hash and the adapter passes whatever you give it — but Mailchimp returns 404 if you pass the original-case email when the lowercase hash is expected. Always lowercase email before hashing if you compute it yourself.\n\n**PUT vs POST**: Mailchimp uses **PUT to upsert** a member (creates if missing, updates if exists) — `mailchimp_upsert_member` does this. POST `/members` would error on duplicate. Stick with the adapter's upsert tool unless you specifically need to fail on duplicate.\n\n**Status values**: when subscribing a member, `status` must be `subscribed`, `unsubscribed`, `cleaned`, `pending` (double opt-in) or `transactional`. Use `subscribed` for direct opt-in, `pending` if the list requires double opt-in.\n\n**Tags**: tags on members are managed via a dedicated endpoint (`/members/{hash}/tags`) — `mailchimp_update_member_tags` accepts an array like `[{name: 'vip', status: 'active'}, {name: 'cold', status: 'inactive'}]`. Setting `active` adds the tag; `inactive` removes it.\n\n**Campaign workflow**: campaigns have 3 phases — create (`POST /campaigns`), set content (`PUT /campaigns/{id}/content` — not exposed here, use the Mailchimp UI for templating), then send (`POST /campaigns/{id}/actions/send`). For pure trigger-on-existing-campaign, just call `mailchimp_send_campaign` with the campaign ID.\n\n**Rate limits**: ~10 concurrent connections per user; daily caps scale with plan. On 429, back off.\n\n**Out of scope here**: webhooks, marketing automation journeys, e-commerce store sync, transactional email (use the separate Mandrill / Mailchimp Transactional API for that), file manager, conversations.", + "region": "intl", + "category": "email", + "icon": "mailchimp", + "docsUrl": "https://mailchimp.com/developer/marketing/api/", + "requiredEnvVars": ["MAILCHIMP_API_KEY", "MAILCHIMP_DC"], + "connector": { + "name": "Mailchimp Marketing API", + "type": "REST", + "baseUrl": "https://{{MAILCHIMP_DC}}.api.mailchimp.com/3.0", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{MAILCHIMP_API_KEY}}" + } + }, + "tools": [ + { + "name": "mailchimp_ping", + "description": "Health check — returns 'Everything's Chimpy!' if the API key and datacenter are valid. Call this at agent startup to confirm credentials before any real work.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/ping" } + }, + { + "name": "mailchimp_get_account_info", + "description": "Return account-level info: company name, contact, plan type, total subscribers across all audiences, industry, language, last login. Useful to introduce the agent to the account context.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/" } + }, + { + "name": "mailchimp_list_audiences", + "description": "List all audiences (formerly 'lists') on the account. Returns each audience's id, name, member_count, date_created, contact info, and default sender. Use this once to discover the list_id you'll pass to other tools.", + "parameters": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "description": "Max audiences per page (default 10, max 1000)." + }, + "offset": { + "type": "integer", + "description": "Pagination offset." + }, + "fields": { + "type": "string", + "description": "Comma-separated paths of fields to return (e.g. 'lists.id,lists.name,lists.stats.member_count'). Trims response." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/lists", + "queryParams": { + "count": "$count", + "offset": "$offset", + "fields": "$fields" + } + } + }, + { + "name": "mailchimp_get_audience", + "description": "Fetch a single audience by ID. Returns full details including stats (member_count, unsubscribe_count, open_rate, click_rate), permission_reminder, double_optin setting, and default sender info.", + "parameters": { + "type": "object", + "properties": { + "listId": { + "type": "string", + "description": "10-character audience (list) ID." + } + }, + "required": ["listId"] + }, + "endpointMapping": { "method": "GET", "path": "/lists/{listId}" } + }, + { + "name": "mailchimp_create_audience", + "description": "Create a new audience. Many fields are required by Mailchimp's compliance rules — contact address, permission reminder, sender from_name/from_email — without them the audience can't legally send emails. Returns the new list id.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Audience name (e.g. 'Newsletter subscribers')." + }, + "contact": { + "type": "object", + "description": "Required postal address: {company, address1, address2, city, state, zip, country (2-letter code), phone}. Mailchimp displays this in the email footer per CAN-SPAM." + }, + "permission_reminder": { + "type": "string", + "description": "Short text shown in every email explaining why the subscriber is receiving it (e.g. 'You signed up for updates at acme.com')." + }, + "campaign_defaults": { + "type": "object", + "description": "{from_name, from_email, subject, language}. Required defaults applied to new campaigns on this list." + }, + "email_type_option": { + "type": "boolean", + "description": "If true, let subscribers choose HTML vs plain-text. Default false." + }, + "double_optin": { + "type": "boolean", + "description": "If true, new subscribers must confirm via email (GDPR-friendly). Default false." + } + }, + "required": ["name", "contact", "permission_reminder", "campaign_defaults"] + }, + "endpointMapping": { + "method": "POST", + "path": "/lists", + "bodyMapping": { + "name": "$name", + "contact": "$contact", + "permission_reminder": "$permission_reminder", + "campaign_defaults": "$campaign_defaults", + "email_type_option": "$email_type_option", + "double_optin": "$double_optin" + } + } + }, + { + "name": "mailchimp_list_members", + "description": "List members of an audience, optionally filtered by status or subscription date. Supports pagination via count + offset.", + "parameters": { + "type": "object", + "properties": { + "listId": { + "type": "string", + "description": "Audience ID." + }, + "status": { + "type": "string", + "description": "Filter by status: subscribed, unsubscribed, cleaned, pending, transactional, archived." + }, + "count": { + "type": "integer", + "description": "Max per page (default 10, max 1000)." + }, + "offset": { + "type": "integer", + "description": "Pagination offset." + }, + "since_timestamp_opt": { + "type": "string", + "description": "Filter members who opted in after this ISO 8601 timestamp." + }, + "fields": { + "type": "string", + "description": "Comma-separated dotted paths to return." + } + }, + "required": ["listId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/lists/{listId}/members", + "queryParams": { + "status": "$status", + "count": "$count", + "offset": "$offset", + "since_timestamp_opt": "$since_timestamp_opt", + "fields": "$fields" + } + } + }, + { + "name": "mailchimp_get_member", + "description": "Fetch a single member by their subscriber_hash (MD5 of lowercase email). Returns email, status, merge_fields (e.g. FNAME, LNAME), tags, stats, last_changed timestamp.", + "parameters": { + "type": "object", + "properties": { + "listId": { + "type": "string", + "description": "Audience ID." + }, + "subscriberHash": { + "type": "string", + "description": "MD5 hash of the lowercase email address." + } + }, + "required": ["listId", "subscriberHash"] + }, + "endpointMapping": { + "method": "GET", + "path": "/lists/{listId}/members/{subscriberHash}" + } + }, + { + "name": "mailchimp_upsert_member", + "description": "Add a member to an audience if missing, or update them if they already exist. Uses PUT — idempotent. Pass the MD5 lowercase-email hash as subscriberHash; `email_address` and `status_if_new` are required for the create path.", + "parameters": { + "type": "object", + "properties": { + "listId": { + "type": "string", + "description": "Audience ID." + }, + "subscriberHash": { + "type": "string", + "description": "MD5 hash of the lowercase email." + }, + "email_address": { + "type": "string", + "description": "Recipient email (lowercase). Required." + }, + "status_if_new": { + "type": "string", + "description": "Status when creating a new member: subscribed, unsubscribed, cleaned, pending, transactional. Required." + }, + "status": { + "type": "string", + "description": "Status when updating an existing member (overrides status_if_new on update)." + }, + "merge_fields": { + "type": "object", + "description": "Merge tags object, e.g. {FNAME: 'Jane', LNAME: 'Doe'}." + }, + "tags": { + "type": "array", + "description": "Array of tag names to apply, e.g. ['vip', 'newsletter']. Replaces existing tags." + }, + "language": { + "type": "string", + "description": "BCP-47 language tag, e.g. 'en', 'es', 'de'." + }, + "vip": { + "type": "boolean", + "description": "Mark member as VIP." + }, + "ip_signup": { + "type": "string", + "description": "IP from which the member signed up (consent record)." + } + }, + "required": ["listId", "subscriberHash", "email_address", "status_if_new"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/lists/{listId}/members/{subscriberHash}", + "bodyMapping": { + "email_address": "$email_address", + "status_if_new": "$status_if_new", + "status": "$status", + "merge_fields": "$merge_fields", + "tags": "$tags", + "language": "$language", + "vip": "$vip", + "ip_signup": "$ip_signup" + } + } + }, + { + "name": "mailchimp_update_member_tags", + "description": "Add or remove tags on a member. Pass an array like [{name: 'vip', status: 'active'}, {name: 'cold', status: 'inactive'}]. 'active' adds the tag; 'inactive' removes it.", + "parameters": { + "type": "object", + "properties": { + "listId": { + "type": "string", + "description": "Audience ID." + }, + "subscriberHash": { + "type": "string", + "description": "MD5 hash of the lowercase email." + }, + "tags": { + "type": "array", + "description": "Array of {name, status} objects. status is 'active' (add) or 'inactive' (remove)." + } + }, + "required": ["listId", "subscriberHash", "tags"] + }, + "endpointMapping": { + "method": "POST", + "path": "/lists/{listId}/members/{subscriberHash}/tags", + "bodyMapping": { + "tags": "$tags" + } + } + }, + { + "name": "mailchimp_delete_member_permanently", + "description": "PERMANENTLY delete a member from an audience — cannot be re-added later (Mailchimp keeps a deletion hash forever for compliance). Use for GDPR right-to-be-forgotten requests only. For normal unsubscribe, use mailchimp_upsert_member with status='unsubscribed'.", + "parameters": { + "type": "object", + "properties": { + "listId": { + "type": "string", + "description": "Audience ID." + }, + "subscriberHash": { + "type": "string", + "description": "MD5 hash of the lowercase email." + } + }, + "required": ["listId", "subscriberHash"] + }, + "endpointMapping": { + "method": "POST", + "path": "/lists/{listId}/members/{subscriberHash}/actions/delete-permanent" + } + }, + { + "name": "mailchimp_list_segments", + "description": "List segments of an audience. Segments are saved filter expressions on members (e.g. 'opened last campaign + lives in EU'). Returns each segment's id, name, member_count, type (static/saved/fuzzy).", + "parameters": { + "type": "object", + "properties": { + "listId": { + "type": "string", + "description": "Audience ID." + }, + "count": { + "type": "integer", + "description": "Max per page." + }, + "type": { + "type": "string", + "description": "Filter by segment type: saved, static, fuzzy." + } + }, + "required": ["listId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/lists/{listId}/segments", + "queryParams": { + "count": "$count", + "type": "$type" + } + } + }, + { + "name": "mailchimp_list_campaigns", + "description": "List campaigns on the account, optionally filtered by status, type, audience or date range. Returns each campaign's id, type, status, send_time, recipients (list_id, segment_id), settings (subject_line, from_name, reply_to), and report_summary (opens, clicks).", + "parameters": { + "type": "object", + "properties": { + "status": { + "type": "string", + "description": "Filter by status: save, paused, schedule, sending, sent." + }, + "type": { + "type": "string", + "description": "Filter by type: regular, plaintext, absplit, rss, variate." + }, + "list_id": { + "type": "string", + "description": "Filter by audience." + }, + "since_send_time": { + "type": "string", + "description": "ISO 8601: only campaigns sent after this time." + }, + "before_send_time": { + "type": "string", + "description": "ISO 8601: only campaigns sent before this time." + }, + "count": { + "type": "integer", + "description": "Max per page." + }, + "offset": { + "type": "integer", + "description": "Pagination offset." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/campaigns", + "queryParams": { + "status": "$status", + "type": "$type", + "list_id": "$list_id", + "since_send_time": "$since_send_time", + "before_send_time": "$before_send_time", + "count": "$count", + "offset": "$offset" + } + } + }, + { + "name": "mailchimp_get_campaign", + "description": "Fetch a single campaign by ID. Returns settings, recipients, tracking config, send_time, and a report_summary with open/click/bounce stats if already sent.", + "parameters": { + "type": "object", + "properties": { + "campaignId": { + "type": "string", + "description": "Campaign ID (web_id is a different short numeric ID — use the alphanumeric id)." + } + }, + "required": ["campaignId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/campaigns/{campaignId}" + } + }, + { + "name": "mailchimp_create_campaign", + "description": "Create a new campaign (does NOT send it). After creation you set its content (template / HTML) via Mailchimp's UI or the `/campaigns/{id}/content` endpoint, then call mailchimp_send_campaign. The recipients object MUST reference an existing list_id and optionally a segment.", + "parameters": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Campaign type: regular, plaintext, absplit, rss, variate. Most agent uses → regular." + }, + "recipients": { + "type": "object", + "description": "{list_id: '...', segment_opts: {saved_segment_id: ...}} — required." + }, + "settings": { + "type": "object", + "description": "{subject_line, preview_text, title, from_name, reply_to, to_name, folder_id, authenticate, auto_footer, inline_css, auto_tweet}. subject_line + reply_to + from_name are typically required." + }, + "tracking": { + "type": "object", + "description": "{opens, html_clicks, text_clicks, goal_tracking, ecomm360, google_analytics, clicktale}. Most defaults are fine." + } + }, + "required": ["type", "recipients", "settings"] + }, + "endpointMapping": { + "method": "POST", + "path": "/campaigns", + "bodyMapping": { + "type": "$type", + "recipients": "$recipients", + "settings": "$settings", + "tracking": "$tracking" + } + } + }, + { + "name": "mailchimp_send_campaign", + "description": "Send (deliver) a previously-created campaign immediately. The campaign must have content set and pass the Mailchimp checklist (subject line, recipient audience, sender, etc.) — otherwise returns 400 with the failing checks.", + "parameters": { + "type": "object", + "properties": { + "campaignId": { + "type": "string", + "description": "Campaign ID to send." + } + }, + "required": ["campaignId"] + }, + "endpointMapping": { + "method": "POST", + "path": "/campaigns/{campaignId}/actions/send" + } + }, + { + "name": "mailchimp_list_templates", + "description": "List templates (user-created and Mailchimp-provided) available on the account. Returns each template's id, name, type (user/base/gallery), category, date_created, thumbnail.", + "parameters": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Filter by type: user, base, gallery." + }, + "category": { + "type": "string", + "description": "Filter by category." + }, + "count": { + "type": "integer", + "description": "Max per page." + }, + "offset": { + "type": "integer", + "description": "Pagination offset." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/templates", + "queryParams": { + "type": "$type", + "category": "$category", + "count": "$count", + "offset": "$offset" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/mailchimp.live.spec.ts b/packages/backend/src/adapters/intl/mailchimp.live.spec.ts new file mode 100644 index 0000000..f35039c --- /dev/null +++ b/packages/backend/src/adapters/intl/mailchimp.live.spec.ts @@ -0,0 +1,94 @@ +import * as adapter from './mailchimp.json'; +import { RestEngine } from '../../connectors/engines/rest.engine'; +import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service'; +import { LoginTokenService } from '../../connectors/engines/login-token.service'; + +/** + * Static + live verification for the Mailchimp adapter. + * + * Live test points at us1 (a real, generic Mailchimp datacenter) with a bogus + * key — expects 401 from Mailchimp's edge, proving the path is recognized. + * + * Run live with: RUN_MAILCHIMP_LIVE=1 npx jest src/adapters/intl/mailchimp.live.spec.ts + */ + +interface Tool { + name: string; + endpointMapping: { method: string; path: string }; +} + +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: Record }; + tools: Tool[]; +}; + +describe('mailchimp adapter — static spec conformance', () => { + it('embeds the datacenter as a {{MAILCHIMP_DC}} placeholder in baseUrl', () => { + expect(a.connector.baseUrl).toContain('{{MAILCHIMP_DC}}'); + expect(a.connector.baseUrl).toContain('api.mailchimp.com/3.0'); + }); + + it('authenticates via Bearer token from MAILCHIMP_API_KEY', () => { + expect(a.connector.authType).toBe('BEARER_TOKEN'); + expect(a.connector.authConfig.token).toBe('{{MAILCHIMP_API_KEY}}'); + }); + + it('upsert-member tool uses PUT (the idempotent upsert verb)', () => { + const upsert = a.tools.find((t) => t.name === 'mailchimp_upsert_member')!; + expect(upsert.endpointMapping.method).toBe('PUT'); + expect(upsert.endpointMapping.path).toBe('/lists/{listId}/members/{subscriberHash}'); + }); + + it('delete-member-permanently uses the actions/delete-permanent path (not DELETE /members)', () => { + const del = a.tools.find((t) => t.name === 'mailchimp_delete_member_permanently')!; + expect(del.endpointMapping.method).toBe('POST'); + expect(del.endpointMapping.path).toBe( + '/lists/{listId}/members/{subscriberHash}/actions/delete-permanent', + ); + }); + + it('send-campaign hits actions/send (not just POST /campaigns/{id})', () => { + const send = a.tools.find((t) => t.name === 'mailchimp_send_campaign')!; + expect(send.endpointMapping.method).toBe('POST'); + expect(send.endpointMapping.path).toBe('/campaigns/{campaignId}/actions/send'); + }); +}); + +const maybe = process.env.RUN_MAILCHIMP_LIVE ? describe : describe.skip; + +maybe('mailchimp adapter — live edge reachability', () => { + const oauth = {} as unknown as OAuth2TokenService; + const login = {} as unknown as LoginTokenService; + const engine = new RestEngine(oauth, login); + const baseUrl = 'https://us1.api.mailchimp.com/3.0'; + + it('GET /ping reaches Mailchimp edge (401 with bogus token)', async () => { + let err: any; + try { + await engine.execute( + { baseUrl, authType: 'BEARER_TOKEN', authConfig: { token: 'bogus-key-us1' } }, + { method: 'GET', path: '/ping' }, + {}, + ); + } catch (e) { + err = e; + } + expect(err).toBeDefined(); + expect(err.response?.status).toBe(401); + }, 30000); + + it('GET / reaches Mailchimp edge', async () => { + let err: any; + try { + await engine.execute( + { baseUrl, authType: 'BEARER_TOKEN', authConfig: { token: 'bogus-key-us1' } }, + { method: 'GET', path: '/' }, + {}, + ); + } catch (e) { + err = e; + } + expect(err).toBeDefined(); + expect(err.response?.status).toBe(401); + }, 30000); +}); diff --git a/packages/backend/src/adapters/intl/pipedrive.json b/packages/backend/src/adapters/intl/pipedrive.json new file mode 100644 index 0000000..cb0a055 --- /dev/null +++ b/packages/backend/src/adapters/intl/pipedrive.json @@ -0,0 +1,899 @@ +{ + "slug": "pipedrive", + "name": "Pipedrive CRM", + "description": "Drive Pipedrive (CRM) from any AI agent: search and manage deals, persons, organizations, activities, pipelines, and stages. 18 ready-to-use tools covering the most common sales workflows. API-token auth via query string, no SDK, no OAuth dance.", + "instructions": "This connector uses the Pipedrive REST API.\n\n**Versions**: most resources (deals, persons, organizations, activities, pipelines, stages, users) are on the newer **v2** API (`/api/v2/...`). The universal item-search endpoint is still on v1 (`/v1/itemSearch`). The adapter mixes both as appropriate — you don't need to know the distinction, just pick the tool.\n\n**Setup**:\n1. Sign in to Pipedrive → top-right avatar → **Personal preferences → API**. Copy the **personal API token** shown there.\n2. Paste it as `PIPEDRIVE_API_TOKEN`.\n3. (Optional) If your account is on a custom company subdomain that requires it, set `PIPEDRIVE_COMPANY_DOMAIN` to your subdomain (e.g. `acme` if your CRM lives at `acme.pipedrive.com`). The default base URL `https://api.pipedrive.com` works for almost everyone since the token already routes to the right company.\n\n**Authentication**: API token in query string (`?api_token=...`) — every request automatically appends it. The token belongs to a single user; everything the connector does is attributed to that user. For multi-user installs, use a dedicated service-account user with the permissions you want the agent to have.\n\n**Pagination**: v2 endpoints use **cursor-based pagination** (`limit` + `cursor`). Each list response carries `additional_data.next_cursor` — pass it as `cursor` on the next call. `limit` defaults to 100, max 500. The adapter's list tools expose both parameters explicitly.\n\n**Search**: for fuzzy text matching across multiple entity types, use `pipedrive_search` (universal). For type-narrowed search inside a known entity, use the per-resource `pipedrive_search_deals` / `pipedrive_search_persons`.\n\n**Custom fields**: every CRM has custom fields. v2 endpoints accept and return them under the `custom_fields` body/response key. The shape is `{field_api_key: value}` — get the API keys from `GET /v1/dealFields` (and `personFields`, `organizationFields`). The adapter ships `pipedrive_list_deal_fields` / `pipedrive_list_person_fields` / `pipedrive_list_organization_fields` for this.\n\n**Rate limits**: Pipedrive applies a per-token rate limit (~80 req / 2 seconds per company, plus daily quotas that scale with plan). On 429, back off — the engine does NOT auto-retry.\n\n**Webhooks not in scope**: Pipedrive can push webhook events for deal/person changes; receiving them requires a separately-hosted endpoint and is outside this connector's read/write scope.\n\n**Filters & sorting**: list endpoints accept `filter_id`, `owner_id`, `status` for deals; `sort` parameter accepts comma-separated field directives like `add_time DESC,update_time DESC` on v2. Check Pipedrive's API reference for the supported sort fields per resource.\n\n**Out of scope here**: leads (v1 only), products, files, mailbox, projects, and goals — add purpose-built tools if your workflows need them.", + "region": "intl", + "category": "crm", + "icon": "pipedrive", + "docsUrl": "https://developers.pipedrive.com/docs/api/v1", + "requiredEnvVars": ["PIPEDRIVE_API_TOKEN"], + "connector": { + "name": "Pipedrive CRM", + "type": "REST", + "baseUrl": "https://api.pipedrive.com", + "authType": "QUERY_AUTH", + "authConfig": { + "api_token": "{{PIPEDRIVE_API_TOKEN}}" + } + }, + "tools": [ + { + "name": "pipedrive_search", + "description": "Universal fuzzy search across deals, persons, organizations, products, files, mailbox messages and projects in one call. Use this when the agent only has a free-text query (a person name, deal title, company name) and doesn't know which entity type to look in. For narrower searches, prefer the per-resource search tools.", + "parameters": { + "type": "object", + "properties": { + "term": { + "type": "string", + "description": "Search query. Min 2 chars normally, min 1 char if exact_match is true." + }, + "item_types": { + "type": "string", + "description": "Comma-separated list of item types to search. Allowed: deal, person, organization, product, file, mail_attachment, project. Omit for all types." + }, + "fields": { + "type": "string", + "description": "Comma-separated fields to search within (e.g. 'title,notes' for deals). Omit to search default fields per type." + }, + "exact_match": { + "type": "boolean", + "description": "If true, return only items whose searched field contains the term as a whole word (case-insensitive). Default false (substring match)." + }, + "limit": { + "type": "integer", + "description": "Max results per page (default 100, max 500)." + } + }, + "required": ["term"] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/itemSearch", + "queryParams": { + "term": "$term", + "item_types": "$item_types", + "fields": "$fields", + "exact_match": "$exact_match", + "limit": "$limit" + } + } + }, + { + "name": "pipedrive_list_deals", + "description": "List deals with optional filtering by owner, person, organization, pipeline, stage, status. Supports cursor pagination. Returns the deal id, title, value, currency, stage_id, status, owner_id, person_id, org_id, add_time, update_time, expected_close_date and custom_fields for each match.", + "parameters": { + "type": "object", + "properties": { + "filter_id": { + "type": "integer", + "description": "Apply a saved filter by its numeric ID." + }, + "owner_id": { + "type": "integer", + "description": "Only deals owned by this user ID." + }, + "person_id": { + "type": "integer", + "description": "Only deals linked to this person ID." + }, + "org_id": { + "type": "integer", + "description": "Only deals linked to this organization ID." + }, + "pipeline_id": { + "type": "integer", + "description": "Only deals in this pipeline ID." + }, + "stage_id": { + "type": "integer", + "description": "Only deals in this stage ID." + }, + "status": { + "type": "string", + "description": "One of: open, won, lost, deleted. Default returns all but deleted." + }, + "sort_by": { + "type": "string", + "description": "Field to sort by, e.g. add_time, update_time, value, expected_close_date." + }, + "sort_direction": { + "type": "string", + "description": "asc or desc. Default desc." + }, + "limit": { + "type": "integer", + "description": "Max deals per page (default 100, max 500)." + }, + "cursor": { + "type": "string", + "description": "Pagination cursor from a prior response's additional_data.next_cursor." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/deals", + "queryParams": { + "filter_id": "$filter_id", + "owner_id": "$owner_id", + "person_id": "$person_id", + "org_id": "$org_id", + "pipeline_id": "$pipeline_id", + "stage_id": "$stage_id", + "status": "$status", + "sort_by": "$sort_by", + "sort_direction": "$sort_direction", + "limit": "$limit", + "cursor": "$cursor" + } + } + }, + { + "name": "pipedrive_get_deal", + "description": "Fetch a single deal by its numeric ID. Returns the full deal object including custom fields and the linked person, organization and owner details.", + "parameters": { + "type": "object", + "properties": { + "dealId": { + "type": "integer", + "description": "Numeric deal ID." + } + }, + "required": ["dealId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/deals/{dealId}" + } + }, + { + "name": "pipedrive_create_deal", + "description": "Create a new deal. Only `title` is required; everything else (value, currency, owner, linked person/org, stage, expected_close_date, custom fields) is optional but recommended for forecasting accuracy.", + "parameters": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Deal title (e.g. 'ACME Inc — Q3 renewal')." + }, + "value": { + "type": "number", + "description": "Monetary value (in the currency specified)." + }, + "currency": { + "type": "string", + "description": "ISO 4217 code, e.g. 'USD', 'EUR'. Defaults to the company's default currency." + }, + "owner_id": { + "type": "integer", + "description": "User ID of the deal owner. Defaults to the API token's user." + }, + "person_id": { + "type": "integer", + "description": "ID of the primary contact person on this deal." + }, + "org_id": { + "type": "integer", + "description": "ID of the organization this deal belongs to." + }, + "pipeline_id": { + "type": "integer", + "description": "Pipeline ID. Defaults to the default pipeline." + }, + "stage_id": { + "type": "integer", + "description": "Stage ID (must belong to the pipeline)." + }, + "status": { + "type": "string", + "description": "open, won, or lost. Default open." + }, + "expected_close_date": { + "type": "string", + "description": "Expected close date in YYYY-MM-DD format." + }, + "probability": { + "type": "number", + "description": "Win probability 0-100. Used in forecasts." + }, + "label_ids": { + "type": "array", + "description": "Array of label IDs to attach." + }, + "visible_to": { + "type": "integer", + "description": "Visibility level: 1 (Owner & followers), 3 (Entire company). Some plans add 5/7 for group sharing." + }, + "custom_fields": { + "type": "object", + "description": "Object of custom field API keys to values. Discover keys via pipedrive_list_deal_fields." + } + }, + "required": ["title"] + }, + "endpointMapping": { + "method": "POST", + "path": "/api/v2/deals", + "bodyMapping": { + "title": "$title", + "value": "$value", + "currency": "$currency", + "owner_id": "$owner_id", + "person_id": "$person_id", + "org_id": "$org_id", + "pipeline_id": "$pipeline_id", + "stage_id": "$stage_id", + "status": "$status", + "expected_close_date": "$expected_close_date", + "probability": "$probability", + "label_ids": "$label_ids", + "visible_to": "$visible_to", + "custom_fields": "$custom_fields" + } + } + }, + { + "name": "pipedrive_update_deal", + "description": "Partial-update a deal — only pass the fields you want to change. Common uses: change stage_id (move deal in pipeline), set status=won|lost (close deal), update value or expected_close_date.", + "parameters": { + "type": "object", + "properties": { + "dealId": { + "type": "integer", + "description": "Numeric deal ID to update." + }, + "title": { + "type": "string", + "description": "New title." + }, + "value": { + "type": "number", + "description": "New value." + }, + "currency": { + "type": "string", + "description": "New currency (ISO 4217)." + }, + "owner_id": { + "type": "integer", + "description": "Reassign owner." + }, + "person_id": { + "type": "integer", + "description": "Change primary person." + }, + "org_id": { + "type": "integer", + "description": "Change linked org." + }, + "stage_id": { + "type": "integer", + "description": "Move to a different stage (within the same or a different pipeline)." + }, + "pipeline_id": { + "type": "integer", + "description": "Move to a different pipeline." + }, + "status": { + "type": "string", + "description": "open, won, lost or deleted." + }, + "expected_close_date": { + "type": "string", + "description": "New expected close date (YYYY-MM-DD)." + }, + "probability": { + "type": "number", + "description": "New probability 0-100." + }, + "label_ids": { + "type": "array", + "description": "Replace label IDs." + }, + "custom_fields": { + "type": "object", + "description": "Custom fields to update." + } + }, + "required": ["dealId"] + }, + "endpointMapping": { + "method": "PATCH", + "path": "/api/v2/deals/{dealId}", + "bodyMapping": { + "title": "$title", + "value": "$value", + "currency": "$currency", + "owner_id": "$owner_id", + "person_id": "$person_id", + "org_id": "$org_id", + "stage_id": "$stage_id", + "pipeline_id": "$pipeline_id", + "status": "$status", + "expected_close_date": "$expected_close_date", + "probability": "$probability", + "label_ids": "$label_ids", + "custom_fields": "$custom_fields" + } + } + }, + { + "name": "pipedrive_delete_deal", + "description": "Soft-delete a deal. The deal moves to the trash and can be restored within 30 days from the Pipedrive UI. To hard-delete, the operator must purge from the UI separately.", + "parameters": { + "type": "object", + "properties": { + "dealId": { + "type": "integer", + "description": "Numeric deal ID to delete." + } + }, + "required": ["dealId"] + }, + "endpointMapping": { + "method": "DELETE", + "path": "/api/v2/deals/{dealId}" + } + }, + { + "name": "pipedrive_search_deals", + "description": "Narrower search than pipedrive_search — only looks inside deals (title and custom fields). Use when the agent knows it's looking for a deal specifically.", + "parameters": { + "type": "object", + "properties": { + "term": { + "type": "string", + "description": "Search query (min 2 chars, min 1 if exact_match is true)." + }, + "fields": { + "type": "string", + "description": "Comma-separated fields to search (title, notes, custom_fields). Default: title, custom_fields." + }, + "exact_match": { + "type": "boolean", + "description": "If true, require whole-word match." + }, + "person_id": { + "type": "integer", + "description": "Limit to deals linked to this person." + }, + "organization_id": { + "type": "integer", + "description": "Limit to deals linked to this organization." + }, + "status": { + "type": "string", + "description": "open, won, lost." + }, + "limit": { + "type": "integer", + "description": "Max results per page." + } + }, + "required": ["term"] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/deals/search", + "queryParams": { + "term": "$term", + "fields": "$fields", + "exact_match": "$exact_match", + "person_id": "$person_id", + "organization_id": "$organization_id", + "status": "$status", + "limit": "$limit" + } + } + }, + { + "name": "pipedrive_list_persons", + "description": "List persons (contacts) with optional filtering by owner, organization, or label. Cursor-paginated. Each person has emails[], phones[], owner_id, org_id, label_ids, marketing_status and custom_fields.", + "parameters": { + "type": "object", + "properties": { + "filter_id": { + "type": "integer", + "description": "Apply a saved filter by ID." + }, + "owner_id": { + "type": "integer", + "description": "Only persons owned by this user ID." + }, + "org_id": { + "type": "integer", + "description": "Only persons linked to this organization." + }, + "sort_by": { + "type": "string", + "description": "add_time, update_time, name, etc." + }, + "sort_direction": { + "type": "string", + "description": "asc or desc." + }, + "limit": { + "type": "integer", + "description": "Max per page (default 100, max 500)." + }, + "cursor": { + "type": "string", + "description": "Pagination cursor." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/persons", + "queryParams": { + "filter_id": "$filter_id", + "owner_id": "$owner_id", + "org_id": "$org_id", + "sort_by": "$sort_by", + "sort_direction": "$sort_direction", + "limit": "$limit", + "cursor": "$cursor" + } + } + }, + { + "name": "pipedrive_get_person", + "description": "Fetch a single person by ID. Returns full contact details including emails, phones, linked organization, owner, label IDs, marketing_status and custom fields.", + "parameters": { + "type": "object", + "properties": { + "personId": { + "type": "integer", + "description": "Numeric person ID." + } + }, + "required": ["personId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/persons/{personId}" + } + }, + { + "name": "pipedrive_create_person", + "description": "Create a new person (contact). Only `name` is required. Emails and phones are arrays of objects: `[{value:'a@b.com', primary:true, label:'work'}]`.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Full name." + }, + "owner_id": { + "type": "integer", + "description": "User ID of the person's owner." + }, + "org_id": { + "type": "integer", + "description": "Linked organization ID." + }, + "emails": { + "type": "array", + "description": "Array of email objects: [{value:'a@b.com', primary:true, label:'work'}]" + }, + "phones": { + "type": "array", + "description": "Array of phone objects: [{value:'+15551234', primary:true, label:'work'}]" + }, + "label_ids": { + "type": "array", + "description": "Array of label IDs to attach." + }, + "marketing_status": { + "type": "string", + "description": "Marketing opt-in status. One of: no_consent, unsubscribed, subscribed, archived." + }, + "visible_to": { + "type": "integer", + "description": "Visibility level: 1 (Owner & followers), 3 (Entire company)." + }, + "custom_fields": { + "type": "object", + "description": "Custom fields object (use pipedrive_list_person_fields to discover keys)." + } + }, + "required": ["name"] + }, + "endpointMapping": { + "method": "POST", + "path": "/api/v2/persons", + "bodyMapping": { + "name": "$name", + "owner_id": "$owner_id", + "org_id": "$org_id", + "emails": "$emails", + "phones": "$phones", + "label_ids": "$label_ids", + "marketing_status": "$marketing_status", + "visible_to": "$visible_to", + "custom_fields": "$custom_fields" + } + } + }, + { + "name": "pipedrive_update_person", + "description": "Partial-update a person. Pass only the fields to change. Emails and phones are REPLACED (not merged) if provided — to add an email, fetch current emails first, append, then send the full array.", + "parameters": { + "type": "object", + "properties": { + "personId": { + "type": "integer", + "description": "Person ID." + }, + "name": { + "type": "string", + "description": "New name." + }, + "owner_id": { + "type": "integer", + "description": "Reassign owner." + }, + "org_id": { + "type": "integer", + "description": "Link to a different organization (or null to unlink)." + }, + "emails": { + "type": "array", + "description": "Full replacement of emails array." + }, + "phones": { + "type": "array", + "description": "Full replacement of phones array." + }, + "label_ids": { + "type": "array", + "description": "Replace label IDs." + }, + "marketing_status": { + "type": "string", + "description": "Update marketing status." + }, + "custom_fields": { + "type": "object", + "description": "Custom fields to update." + } + }, + "required": ["personId"] + }, + "endpointMapping": { + "method": "PATCH", + "path": "/api/v2/persons/{personId}", + "bodyMapping": { + "name": "$name", + "owner_id": "$owner_id", + "org_id": "$org_id", + "emails": "$emails", + "phones": "$phones", + "label_ids": "$label_ids", + "marketing_status": "$marketing_status", + "custom_fields": "$custom_fields" + } + } + }, + { + "name": "pipedrive_list_organizations", + "description": "List organizations (companies) with optional filtering by owner or saved filter. Cursor-paginated. Each org has name, address, owner_id, label_ids, custom_fields.", + "parameters": { + "type": "object", + "properties": { + "filter_id": { + "type": "integer", + "description": "Saved filter ID." + }, + "owner_id": { + "type": "integer", + "description": "Owner user ID." + }, + "sort_by": { + "type": "string", + "description": "add_time, update_time, name." + }, + "sort_direction": { + "type": "string", + "description": "asc or desc." + }, + "limit": { + "type": "integer", + "description": "Max per page." + }, + "cursor": { + "type": "string", + "description": "Pagination cursor." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/organizations", + "queryParams": { + "filter_id": "$filter_id", + "owner_id": "$owner_id", + "sort_by": "$sort_by", + "sort_direction": "$sort_direction", + "limit": "$limit", + "cursor": "$cursor" + } + } + }, + { + "name": "pipedrive_create_organization", + "description": "Create a new organization. Only `name` is required.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Organization name." + }, + "owner_id": { + "type": "integer", + "description": "Owner user ID." + }, + "address": { + "type": "string", + "description": "Full street address as a single string." + }, + "label_ids": { + "type": "array", + "description": "Label IDs." + }, + "visible_to": { + "type": "integer", + "description": "Visibility level." + }, + "custom_fields": { + "type": "object", + "description": "Custom fields." + } + }, + "required": ["name"] + }, + "endpointMapping": { + "method": "POST", + "path": "/api/v2/organizations", + "bodyMapping": { + "name": "$name", + "owner_id": "$owner_id", + "address": "$address", + "label_ids": "$label_ids", + "visible_to": "$visible_to", + "custom_fields": "$custom_fields" + } + } + }, + { + "name": "pipedrive_list_activities", + "description": "List activities (calls, meetings, tasks, emails, etc.) with optional filtering by user, deal, person, type or date range. Activities are the touchpoint log of a CRM and key signal for forecast.", + "parameters": { + "type": "object", + "properties": { + "user_id": { + "type": "integer", + "description": "Filter by assigned user." + }, + "deal_id": { + "type": "integer", + "description": "Filter by linked deal." + }, + "person_id": { + "type": "integer", + "description": "Filter by linked person." + }, + "org_id": { + "type": "integer", + "description": "Filter by linked organization." + }, + "type": { + "type": "string", + "description": "Activity type key (call, meeting, task, email, deadline, lunch, ...). Use pipedrive_list_activity_types to discover." + }, + "done": { + "type": "boolean", + "description": "true = only completed activities; false = only open ones; omit for all." + }, + "start_date": { + "type": "string", + "description": "From date (YYYY-MM-DD)." + }, + "end_date": { + "type": "string", + "description": "To date (YYYY-MM-DD)." + }, + "limit": { + "type": "integer", + "description": "Max per page." + }, + "cursor": { + "type": "string", + "description": "Pagination cursor." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/activities", + "queryParams": { + "user_id": "$user_id", + "deal_id": "$deal_id", + "person_id": "$person_id", + "org_id": "$org_id", + "type": "$type", + "done": "$done", + "start_date": "$start_date", + "end_date": "$end_date", + "limit": "$limit", + "cursor": "$cursor" + } + } + }, + { + "name": "pipedrive_create_activity", + "description": "Log an activity (call, meeting, task, email, etc.). Link to a deal/person/org for context. Setting `due_date` + `due_time` schedules it; omitting both creates an untimed task.", + "parameters": { + "type": "object", + "properties": { + "subject": { + "type": "string", + "description": "Activity title (e.g. 'Discovery call with John')." + }, + "type": { + "type": "string", + "description": "Activity type key (call, meeting, task, email, deadline, lunch). Required." + }, + "due_date": { + "type": "string", + "description": "Due date (YYYY-MM-DD)." + }, + "due_time": { + "type": "string", + "description": "Due time (HH:MM, 24h)." + }, + "duration": { + "type": "string", + "description": "Planned duration (HH:MM). Used for calendar blocking." + }, + "deal_id": { + "type": "integer", + "description": "Link to a deal." + }, + "person_id": { + "type": "integer", + "description": "Link to a person." + }, + "org_id": { + "type": "integer", + "description": "Link to an organization." + }, + "user_id": { + "type": "integer", + "description": "Assigned user. Defaults to the API token's user." + }, + "note": { + "type": "string", + "description": "Free-form note (HTML supported)." + }, + "done": { + "type": "boolean", + "description": "If true, mark as already completed." + } + }, + "required": ["subject", "type"] + }, + "endpointMapping": { + "method": "POST", + "path": "/api/v2/activities", + "bodyMapping": { + "subject": "$subject", + "type": "$type", + "due_date": "$due_date", + "due_time": "$due_time", + "duration": "$duration", + "deal_id": "$deal_id", + "person_id": "$person_id", + "org_id": "$org_id", + "user_id": "$user_id", + "note": "$note", + "done": "$done" + } + } + }, + { + "name": "pipedrive_list_pipelines", + "description": "List all pipelines on this Pipedrive account. Each pipeline returns its id, name, order_nr (display order) and active flag. Pipelines are the funnels deals move through.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/pipelines" + } + }, + { + "name": "pipedrive_list_stages", + "description": "List stages, optionally filtered by pipeline. Each stage has id, name, pipeline_id, order_nr, deal_probability (default win-rate %) and rotten_flag (whether stale deals turn rotten). Use the stage IDs when creating or moving deals.", + "parameters": { + "type": "object", + "properties": { + "pipeline_id": { + "type": "integer", + "description": "If set, only stages in this pipeline." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/stages", + "queryParams": { + "pipeline_id": "$pipeline_id" + } + } + }, + { + "name": "pipedrive_list_users", + "description": "List users on this Pipedrive account (sales reps, admins). Use to map user IDs to names when displaying deal owners.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/users" + } + }, + { + "name": "pipedrive_get_current_user", + "description": "Return the user account that the API token belongs to (whoami). Useful at agent startup to know whose perspective the agent operates from.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/users/me" + } + }, + { + "name": "pipedrive_list_deal_fields", + "description": "List all deal fields including custom fields with their API keys, types, and pick-list options. Required to compose the custom_fields object for pipedrive_create_deal / pipedrive_update_deal.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/dealFields" + } + }, + { + "name": "pipedrive_list_person_fields", + "description": "List all person fields including custom fields with their API keys. Required to compose the custom_fields object for pipedrive_create_person / pipedrive_update_person.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/personFields" + } + }, + { + "name": "pipedrive_list_organization_fields", + "description": "List all organization fields including custom fields with their API keys. Required to compose the custom_fields object for pipedrive_create_organization.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/organizationFields" + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/pipedrive.live.spec.ts b/packages/backend/src/adapters/intl/pipedrive.live.spec.ts new file mode 100644 index 0000000..5f50609 --- /dev/null +++ b/packages/backend/src/adapters/intl/pipedrive.live.spec.ts @@ -0,0 +1,155 @@ +import * as adapter from './pipedrive.json'; +import { RestEngine } from '../../connectors/engines/rest.engine'; +import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service'; +import { LoginTokenService } from '../../connectors/engines/login-token.service'; + +/** + * Two-tier verification (pattern: weclapp, whatsapp-business). + * + * 1. Static — always runs in CI. Asserts the adapter shape matches the + * Pipedrive REST conventions: mixed v1 + v2 base, QUERY_AUTH with + * api_token, expected paths for the discover-first tools. + * + * 2. Live — opt-in via RUN_PIPEDRIVE_LIVE=1. Hits api.pipedrive.com with a + * bogus api_token, asserts a 401 from Pipedrive's edge — proves the + * endpoint path is recognized. + * + * Run live with: + * RUN_PIPEDRIVE_LIVE=1 npx jest src/adapters/intl/pipedrive.live.spec.ts + */ + +interface Tool { + name: string; + endpointMapping: { method: string; path: string }; +} + +const a = adapter as unknown as { + slug: string; + connector: { baseUrl: string; authType: string; authConfig: Record }; + tools: Tool[]; +}; + +describe('pipedrive adapter — static spec conformance', () => { + it('uses api.pipedrive.com as the base URL', () => { + expect(a.connector.baseUrl).toBe('https://api.pipedrive.com'); + }); + + it('authenticates via api_token in the query string (QUERY_AUTH)', () => { + expect(a.connector.authType).toBe('QUERY_AUTH'); + expect(a.connector.authConfig.api_token).toBe('{{PIPEDRIVE_API_TOKEN}}'); + }); + + it('uses v2 paths for deals/persons/organizations/activities/pipelines/stages', () => { + const v2Tools = [ + 'pipedrive_list_deals', + 'pipedrive_get_deal', + 'pipedrive_create_deal', + 'pipedrive_update_deal', + 'pipedrive_delete_deal', + 'pipedrive_search_deals', + 'pipedrive_list_persons', + 'pipedrive_get_person', + 'pipedrive_create_person', + 'pipedrive_update_person', + 'pipedrive_list_organizations', + 'pipedrive_create_organization', + 'pipedrive_list_activities', + 'pipedrive_create_activity', + 'pipedrive_list_pipelines', + 'pipedrive_list_stages', + ]; + for (const name of v2Tools) { + const tool = a.tools.find((t) => t.name === name); + expect(tool).toBeDefined(); + expect(tool!.endpointMapping.path.startsWith('/api/v2/')).toBe(true); + } + }); + + it('still uses v1 for itemSearch, users and field-discovery tools', () => { + const v1Tools = [ + ['pipedrive_search', '/v1/itemSearch'], + ['pipedrive_list_users', '/v1/users'], + ['pipedrive_get_current_user', '/v1/users/me'], + ['pipedrive_list_deal_fields', '/v1/dealFields'], + ['pipedrive_list_person_fields', '/v1/personFields'], + ['pipedrive_list_organization_fields', '/v1/organizationFields'], + ]; + for (const [name, path] of v1Tools) { + const tool = a.tools.find((t) => t.name === name); + expect(tool).toBeDefined(); + expect(tool!.endpointMapping.path).toBe(path); + } + }); + + it('uses PATCH (not PUT) for updates per Pipedrive v2 convention', () => { + const update = a.tools.find((t) => t.name === 'pipedrive_update_deal')!; + expect(update.endpointMapping.method).toBe('PATCH'); + const updatePerson = a.tools.find((t) => t.name === 'pipedrive_update_person')!; + expect(updatePerson.endpointMapping.method).toBe('PATCH'); + }); +}); + +const maybe = process.env.RUN_PIPEDRIVE_LIVE ? describe : describe.skip; + +maybe('pipedrive adapter — live edge reachability', () => { + const oauth = {} as unknown as OAuth2TokenService; + const login = {} as unknown as LoginTokenService; + const engine = new RestEngine(oauth, login); + + it('GET /v1/users/me reaches Pipedrive edge (401 with bogus token)', async () => { + let err: any; + try { + await engine.execute( + { + baseUrl: a.connector.baseUrl, + authType: 'QUERY_AUTH', + authConfig: { api_token: 'bogus-token-for-endpoint-validation' }, + }, + { method: 'GET', path: '/v1/users/me' }, + {}, + ); + } catch (e) { + err = e; + } + expect(err).toBeDefined(); + expect(err.response?.status).toBe(401); + }, 30000); + + it('GET /api/v2/deals reaches Pipedrive edge (401 with bogus token)', async () => { + let err: any; + try { + await engine.execute( + { + baseUrl: a.connector.baseUrl, + authType: 'QUERY_AUTH', + authConfig: { api_token: 'bogus-token' }, + }, + { method: 'GET', path: '/api/v2/deals', queryParams: { limit: '$limit' } }, + { limit: 1 }, + ); + } catch (e) { + err = e; + } + expect(err).toBeDefined(); + expect(err.response?.status).toBe(401); + }, 30000); + + it('api_token is actually injected as a query string parameter', async () => { + let err: any; + try { + await engine.execute( + { + baseUrl: a.connector.baseUrl, + authType: 'QUERY_AUTH', + authConfig: { api_token: 'sentinel-token-12345' }, + }, + { method: 'GET', path: '/v1/users/me' }, + {}, + ); + } catch (e) { + err = e; + } + expect(err).toBeDefined(); + expect(err.config?.params?.api_token).toBe('sentinel-token-12345'); + }, 30000); +}); diff --git a/packages/backend/src/adapters/intl/sendgrid.json b/packages/backend/src/adapters/intl/sendgrid.json new file mode 100644 index 0000000..ebe97fe --- /dev/null +++ b/packages/backend/src/adapters/intl/sendgrid.json @@ -0,0 +1,451 @@ +{ + "slug": "sendgrid", + "name": "SendGrid", + "description": "Send transactional and marketing emails via SendGrid's v3 API from any AI agent. 14 tools covering send, templates, stats, suppressions, contacts and marketing lists. Bearer-token auth, EU + Global datacenter support.", + "instructions": "This connector uses the SendGrid v3 REST API.\n\n**Setup**:\n1. Sign in to SendGrid (or via Twilio's console — SendGrid is part of Twilio since 2019) → **Settings → API Keys → Create API Key**.\n2. Pick **Restricted Access** and grant at least: Mail Send (Full Access), Template Engine (Read), Stats (Read), Suppressions (Read), Marketing > Contacts (Read+Write), Marketing > Lists (Read+Write). Full Access also works but follows least-privilege poorly.\n3. Copy the key — it's shown ONCE. Set `SENDGRID_API_KEY` to it.\n4. (Optional) If your account is on the **EU datacenter** (GDPR data residency), set `SENDGRID_BASE_URL` to `https://api.eu.sendgrid.com`. Default is the global `https://api.sendgrid.com`.\n\n**Authentication**: Bearer token (`Authorization: Bearer ${SENDGRID_API_KEY}`).\n\n**Sender authentication first!** SendGrid will silently quarantine emails from un-verified senders. Before sending production mail, verify either:\n - **Single Sender** (quick, for low volume): Settings → Sender Authentication → Verify a Single Sender. SendGrid emails a verification link.\n - **Domain Authentication** (recommended for any real volume): Settings → Sender Authentication → Authenticate Your Domain. Adds CNAME records to your DNS.\nThe `from.email` in `sendgrid_send_mail` must match an authenticated sender, otherwise emails will be silently rejected or marked as spam.\n\n**Mail send body model**: SendGrid uses a message-level + per-recipient model. The `personalizations` array lets you send N customized versions in one API call (each with its own `to`, `subject`, `substitutions`). For a simple 'send the same email to one person' call, use a single personalization with one `to`.\n\n**Content order matters**: when sending both plain-text and HTML, list `text/plain` BEFORE `text/html` in the `content` array per RFC 2046.\n\n**Templates (dynamic)**: SendGrid's modern templates are **Dynamic Templates** (Handlebars-based). To send one, omit `subject`/`content`, set `template_id` at the message level, and pass `dynamic_template_data` per personalization. The legacy Transactional Templates use substitutions and are still supported but deprecated.\n\n**Categories** (≤10 per email, alphanumeric + underscore): tag your sends to group them in Stats. The adapter accepts `categories` at the top of `sendgrid_send_mail`.\n\n**Suppression precedence**: bounces, blocks, invalid emails, spam reports, and unsubscribes all live in the suppression group. Even a perfectly authenticated message to a suppressed address won't deliver. Use `sendgrid_list_bounces` and remove with `sendgrid_delete_bounce` (not exposed here — bounce removal must be explicit and is GDPR-sensitive, do it via the SendGrid UI).\n\n**Marketing Contacts**: contacts are NOT the same as suppression list. The Marketing API (`/v3/marketing/contacts`) is for the Marketing Campaigns product (newsletters), separate from transactional Mail Send. Adding a contact to Marketing does NOT affect deliverability of transactional mail to that address.\n\n**Rate limits**: Mail Send caps depend on plan (Essentials: 100k/month; Pro: 1.5M/month, etc.). Burst limits ~600 req/min for transactional, higher with dedicated IPs. On 429, back off.\n\n**Webhooks (Event Webhook)** out of scope for this connector — host your own receiver to ingest delivery/bounce/open/click events.\n\n**Out of scope here**: subuser management, IP warmup, A/B testing, single sends, designs, and the legacy Web v2 API.", + "region": "intl", + "category": "email", + "icon": "sendgrid", + "docsUrl": "https://www.twilio.com/docs/sendgrid/api-reference", + "requiredEnvVars": ["SENDGRID_API_KEY"], + "connector": { + "name": "SendGrid v3", + "type": "REST", + "baseUrl": "https://api.sendgrid.com", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{SENDGRID_API_KEY}}" + } + }, + "tools": [ + { + "name": "sendgrid_send_mail", + "description": "Send a transactional or batch email via the v3 Mail Send endpoint. Required at minimum: `personalizations[0].to[0].email`, `from.email`, `subject`, `content[0]`. Use the `template_id` + `dynamic_template_data` path for templated sends and skip subject/content. Returns 202 Accepted with X-Message-Id header on success.", + "parameters": { + "type": "object", + "properties": { + "personalizations": { + "type": "array", + "description": "Array of recipient batches (1-1000). Each is {to:[{email,name?}], cc?, bcc?, subject?, headers?, substitutions?, dynamic_template_data?, custom_args?, send_at?}. For a single recipient, use one personalization with one `to`." + }, + "from": { + "type": "object", + "description": "Sender: {email, name?}. `email` MUST match a SendGrid-authenticated sender. Required." + }, + "subject": { + "type": "string", + "description": "Default subject (per-personalization subject overrides this). Required unless every personalization has its own subject OR you're using a dynamic template that sets subject." + }, + "content": { + "type": "array", + "description": "Array of MIME parts: [{type:'text/plain', value:'...'}, {type:'text/html', value:'...'}]. text/plain MUST come before text/html. Required unless using template_id." + }, + "reply_to": { + "type": "object", + "description": "{email, name?}. Reply-To header." + }, + "template_id": { + "type": "string", + "description": "If set, use this Dynamic Template — usually 'd-XXXXXXXX'. Omit subject and content in this case (the template provides them). Pass per-personalization data via `dynamic_template_data`." + }, + "categories": { + "type": "array", + "description": "Up to 10 string categories for stats grouping (alphanumeric + underscore, max 255 chars each)." + }, + "custom_args": { + "type": "object", + "description": "Arbitrary key/value attached to event webhook payloads. Up to 10KB total." + }, + "send_at": { + "type": "integer", + "description": "Unix timestamp to schedule the send (up to 72 hours in the future). Omit to send immediately." + }, + "asm": { + "type": "object", + "description": "Unsubscribe group: {group_id, groups_to_display?}. Required for compliant marketing email if you've defined groups." + }, + "tracking_settings": { + "type": "object", + "description": "Override tracking: {click_tracking:{enable,enable_text}, open_tracking:{enable,substitution_tag}, subscription_tracking:{enable,text,html,substitution_tag}, ganalytics:{enable, utm_source, utm_medium, utm_campaign, utm_term, utm_content}}." + }, + "mail_settings": { + "type": "object", + "description": "Message-level settings: {bypass_list_management:{enable}, sandbox_mode:{enable}, footer:{enable,text,html}}. sandbox_mode=true validates without sending (use for tests)." + }, + "attachments": { + "type": "array", + "description": "Array of {content (base64), type, filename, disposition?, content_id?}. Max 30MB total." + }, + "headers": { + "type": "object", + "description": "Additional message-level headers, e.g. {'X-Internal-Ref': 'abc'}. Cannot override reserved headers." + } + }, + "required": ["personalizations", "from"] + }, + "endpointMapping": { + "method": "POST", + "path": "/v3/mail/send", + "bodyMapping": { + "personalizations": "$personalizations", + "from": "$from", + "subject": "$subject", + "content": "$content", + "reply_to": "$reply_to", + "template_id": "$template_id", + "categories": "$categories", + "custom_args": "$custom_args", + "send_at": "$send_at", + "asm": "$asm", + "tracking_settings": "$tracking_settings", + "mail_settings": "$mail_settings", + "attachments": "$attachments", + "headers": "$headers" + } + } + }, + { + "name": "sendgrid_list_templates", + "description": "List transactional templates (legacy or dynamic) on the account. Returns each template's id, name, generation (legacy|dynamic), and versions[].", + "parameters": { + "type": "object", + "properties": { + "generations": { + "type": "string", + "description": "Filter: 'legacy', 'dynamic', or 'legacy,dynamic'. Default returns dynamic only." + }, + "page_size": { + "type": "integer", + "description": "Max per page (default 200, max 200)." + }, + "page_token": { + "type": "string", + "description": "Pagination cursor from previous response's _metadata.next." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v3/templates", + "queryParams": { + "generations": "$generations", + "page_size": "$page_size", + "page_token": "$page_token" + } + } + }, + { + "name": "sendgrid_get_template", + "description": "Fetch a single template by id, including all its versions. Useful to inspect dynamic template variables before composing a sendgrid_send_mail with `template_id`.", + "parameters": { + "type": "object", + "properties": { + "templateId": { + "type": "string", + "description": "Template ID (e.g. 'd-1234abcd5678efgh9012ijkl3456mnop')." + } + }, + "required": ["templateId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/v3/templates/{templateId}" + } + }, + { + "name": "sendgrid_get_stats", + "description": "Get aggregated email statistics across a date range: requests, delivered, opens, unique_opens, clicks, unique_clicks, bounces, spam_reports, unsubscribes. Aggregated daily by default.", + "parameters": { + "type": "object", + "properties": { + "start_date": { + "type": "string", + "description": "Start date YYYY-MM-DD. Required." + }, + "end_date": { + "type": "string", + "description": "End date YYYY-MM-DD. Default today." + }, + "aggregated_by": { + "type": "string", + "description": "day, week, or month." + }, + "categories": { + "type": "string", + "description": "Comma-separated category filter — only stats for emails tagged with these categories." + } + }, + "required": ["start_date"] + }, + "endpointMapping": { + "method": "GET", + "path": "/v3/stats", + "queryParams": { + "start_date": "$start_date", + "end_date": "$end_date", + "aggregated_by": "$aggregated_by", + "categories": "$categories" + } + } + }, + { + "name": "sendgrid_list_bounces", + "description": "List bounced email addresses. Hard bounces (5xx) and soft bounces (4xx) are both reported. Recipients on this list will NOT receive transactional mail until removed via the SendGrid UI (removal is intentionally outside MCP scope to prevent automation from re-sending to bad addresses).", + "parameters": { + "type": "object", + "properties": { + "start_time": { + "type": "integer", + "description": "Unix timestamp — only bounces after this time." + }, + "end_time": { + "type": "integer", + "description": "Unix timestamp — only bounces before this time." + }, + "limit": { + "type": "integer", + "description": "Max per page (default 500, max 500)." + }, + "offset": { + "type": "integer", + "description": "Pagination offset." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v3/suppression/bounces", + "queryParams": { + "start_time": "$start_time", + "end_time": "$end_time", + "limit": "$limit", + "offset": "$offset" + } + } + }, + { + "name": "sendgrid_get_bounce", + "description": "Get the bounce details for a specific email address (reason code, timestamp, status).", + "parameters": { + "type": "object", + "properties": { + "email": { + "type": "string", + "description": "Email address to look up." + } + }, + "required": ["email"] + }, + "endpointMapping": { + "method": "GET", + "path": "/v3/suppression/bounces/{email}" + } + }, + { + "name": "sendgrid_list_invalid_emails", + "description": "List addresses SendGrid rejected as malformed at submission time (not bounces — these never made it out). Use to clean up your contact source.", + "parameters": { + "type": "object", + "properties": { + "start_time": { + "type": "integer", + "description": "Unix timestamp filter." + }, + "end_time": { + "type": "integer", + "description": "Unix timestamp filter." + }, + "limit": { + "type": "integer", + "description": "Max per page." + }, + "offset": { + "type": "integer", + "description": "Pagination offset." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v3/suppression/invalid_emails", + "queryParams": { + "start_time": "$start_time", + "end_time": "$end_time", + "limit": "$limit", + "offset": "$offset" + } + } + }, + { + "name": "sendgrid_list_spam_reports", + "description": "List recipients who reported your emails as spam. These addresses are auto-suppressed.", + "parameters": { + "type": "object", + "properties": { + "start_time": { + "type": "integer", + "description": "Unix timestamp filter." + }, + "end_time": { + "type": "integer", + "description": "Unix timestamp filter." + }, + "limit": { + "type": "integer", + "description": "Max per page." + }, + "offset": { + "type": "integer", + "description": "Pagination offset." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v3/suppression/spam_reports", + "queryParams": { + "start_time": "$start_time", + "end_time": "$end_time", + "limit": "$limit", + "offset": "$offset" + } + } + }, + { + "name": "sendgrid_list_marketing_contacts", + "description": "List marketing contacts (NOT transactional recipients — these live in the Marketing Campaigns product). Returns id, email, first_name, last_name, list_ids[], custom_fields.", + "parameters": { + "type": "object", + "properties": { + "page_size": { + "type": "integer", + "description": "Max per page (default 50, max 1000)." + }, + "page_token": { + "type": "string", + "description": "Pagination cursor." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v3/marketing/contacts", + "queryParams": { + "page_size": "$page_size", + "page_token": "$page_token" + } + } + }, + { + "name": "sendgrid_upsert_marketing_contacts", + "description": "Add or update up to 30,000 marketing contacts in one PUT call. SendGrid runs an async job — returns a job_id; use sendgrid_get_marketing_import_job_status to track. `list_ids` adds the contacts to those lists.", + "parameters": { + "type": "object", + "properties": { + "list_ids": { + "type": "array", + "description": "Array of list UUIDs to add the contacts to. Optional — omit to upsert without list assignment." + }, + "contacts": { + "type": "array", + "description": "Array of contact objects (max 30000). Each: {email (required), first_name?, last_name?, address_line_1?, address_line_2?, city?, state_province_region?, postal_code?, country?, alternate_emails?, phone_number?, custom_fields?}." + } + }, + "required": ["contacts"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/v3/marketing/contacts", + "bodyMapping": { + "list_ids": "$list_ids", + "contacts": "$contacts" + } + } + }, + { + "name": "sendgrid_get_marketing_import_job_status", + "description": "Check the status of a contacts-upsert job returned by sendgrid_upsert_marketing_contacts. Returns status (pending, completed, errored, failed) and counts of upserted/errored rows.", + "parameters": { + "type": "object", + "properties": { + "jobId": { + "type": "string", + "description": "Job UUID from a prior upsert response." + } + }, + "required": ["jobId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/v3/marketing/contacts/imports/{jobId}" + } + }, + { + "name": "sendgrid_list_marketing_lists", + "description": "List all Marketing Campaigns contact lists. Returns id (UUID), name, contact_count, _metadata.", + "parameters": { + "type": "object", + "properties": { + "page_size": { + "type": "integer", + "description": "Max per page (default 100, max 1000)." + }, + "page_token": { + "type": "string", + "description": "Pagination cursor." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v3/marketing/lists", + "queryParams": { + "page_size": "$page_size", + "page_token": "$page_token" + } + } + }, + { + "name": "sendgrid_create_marketing_list", + "description": "Create a new Marketing Campaigns contact list. Returns the new list's id (UUID) — use it in sendgrid_upsert_marketing_contacts to add contacts.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "List name (≤100 chars, unique within account)." + } + }, + "required": ["name"] + }, + "endpointMapping": { + "method": "POST", + "path": "/v3/marketing/lists", + "bodyMapping": { + "name": "$name" + } + } + }, + { + "name": "sendgrid_search_marketing_contacts", + "description": "Search marketing contacts using SendGrid Query Language (similar to SQL WHERE). Returns matching contacts with full details. Example query: \"email LIKE '%@acme.com' AND CONTAINS(list_ids, 'list-uuid-here')\".", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "SGQL query string. See https://docs.sendgrid.com/api-reference/contacts/search-contacts for grammar." + } + }, + "required": ["query"] + }, + "endpointMapping": { + "method": "POST", + "path": "/v3/marketing/contacts/search", + "bodyMapping": { + "query": "$query" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/sendgrid.live.spec.ts b/packages/backend/src/adapters/intl/sendgrid.live.spec.ts new file mode 100644 index 0000000..8a58974 --- /dev/null +++ b/packages/backend/src/adapters/intl/sendgrid.live.spec.ts @@ -0,0 +1,56 @@ +import * as adapter from './sendgrid.json'; +import { RestEngine } from '../../connectors/engines/rest.engine'; +import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service'; +import { LoginTokenService } from '../../connectors/engines/login-token.service'; + +/** + * Live: RUN_SENDGRID_LIVE=1 npx jest src/adapters/intl/sendgrid.live.spec.ts + */ + +interface Tool { name: string; endpointMapping: { method: string; path: string } } +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: Record }; + tools: Tool[]; +}; + +describe('sendgrid adapter — static spec conformance', () => { + it('uses the global SendGrid base URL by default', () => { + expect(a.connector.baseUrl).toBe('https://api.sendgrid.com'); + }); + + it('Bearer auth with SENDGRID_API_KEY', () => { + expect(a.connector.authType).toBe('BEARER_TOKEN'); + expect(a.connector.authConfig.token).toBe('{{SENDGRID_API_KEY}}'); + }); + + it('mail send uses POST /v3/mail/send', () => { + const t = a.tools.find((x) => x.name === 'sendgrid_send_mail')!; + expect(t.endpointMapping.method).toBe('POST'); + expect(t.endpointMapping.path).toBe('/v3/mail/send'); + }); + + it('marketing contacts upsert uses PUT (idempotent)', () => { + const t = a.tools.find((x) => x.name === 'sendgrid_upsert_marketing_contacts')!; + expect(t.endpointMapping.method).toBe('PUT'); + expect(t.endpointMapping.path).toBe('/v3/marketing/contacts'); + }); +}); + +const maybe = process.env.RUN_SENDGRID_LIVE ? describe : describe.skip; + +maybe('sendgrid adapter — live edge reachability', () => { + const engine = new RestEngine({} as OAuth2TokenService, {} as LoginTokenService); + + it('GET /v3/templates reaches SendGrid edge (401 with bogus key)', async () => { + let err: any; + try { + await engine.execute( + { baseUrl: a.connector.baseUrl, authType: 'BEARER_TOKEN', authConfig: { token: 'SG.bogus' } }, + { method: 'GET', path: '/v3/templates' }, + {}, + ); + } catch (e) { err = e; } + expect(err).toBeDefined(); + expect(err.response?.status).toBe(401); + }, 30000); +}); From e7df7fc1db4af9bf1aadeec8bace124f52e5305e Mon Sep 17 00:00:00 2001 From: Matteo Date: Wed, 20 May 2026 12:09:17 +0200 Subject: [PATCH 03/19] connectors: add Calendly, Telegram Bot, Discord Bot adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Calendly v2 (Scheduling): 12 tools — event types, availability windows, scheduled events, invitees, single-use scheduling links, cancellation (POST not DELETE per Calendly convention), organization memberships. Personal access token Bearer auth. Heavy use of URI-as-ID model — instructions field documents it. - Telegram Bot API: 13 tools — text/photo/document/audio/voice (OGG-Opus)/video/location/poll send, message edit/delete, chat info, getUpdates pull. authType=NONE because the bot token lives in the URL path (Telegram-specific) — the {{TELEGRAM_BOT_TOKEN}} template substitutes into baseUrl. - Discord REST API v10 (bot): 13 tools — guilds/channels/messages list, send/edit/delete message, reactions (PUT), DM channel open, thread spawn, guild member fetch. API_KEY auth with literal 'Bot ' prefix (NOT 'Bearer') as Discord requires. Inbound gateway, voice, slash commands explicitly out of scope. Catalog now at 46 adapters. All pass validator hard gate. --- packages/backend/src/adapters/catalog.ts | 6 + .../backend/src/adapters/intl/calendly.json | 253 +++++++++++++ .../src/adapters/intl/calendly.live.spec.ts | 49 +++ .../src/adapters/intl/discord-bot.json | 250 +++++++++++++ .../adapters/intl/discord-bot.live.spec.ts | 58 +++ .../src/adapters/intl/telegram-bot.json | 345 ++++++++++++++++++ .../adapters/intl/telegram-bot.live.spec.ts | 58 +++ 7 files changed, 1019 insertions(+) create mode 100644 packages/backend/src/adapters/intl/calendly.json create mode 100644 packages/backend/src/adapters/intl/calendly.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/discord-bot.json create mode 100644 packages/backend/src/adapters/intl/discord-bot.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/telegram-bot.json create mode 100644 packages/backend/src/adapters/intl/telegram-bot.live.spec.ts diff --git a/packages/backend/src/adapters/catalog.ts b/packages/backend/src/adapters/catalog.ts index e8dad3e..1bd34ee 100644 --- a/packages/backend/src/adapters/catalog.ts +++ b/packages/backend/src/adapters/catalog.ts @@ -31,10 +31,13 @@ import * as weclapp from './de/weclapp.json'; import * as xentral from './de/xentral.json'; import * as companiesHouse from './gb/companies-house.json'; import * as wise from './gb/wise.json'; +import * as calendly from './intl/calendly.json'; +import * as discordBot from './intl/discord-bot.json'; import * as mailchimp from './intl/mailchimp.json'; import * as pipedrive from './intl/pipedrive.json'; import * as sendgrid from './intl/sendgrid.json'; import * as sorare from './intl/sorare.json'; +import * as telegramBot from './intl/telegram-bot.json'; import * as whatsappBusiness from './intl/whatsapp-business.json'; import * as woocommerce from './intl/woocommerce.json'; import * as wordpress from './intl/wordpress.json'; @@ -146,10 +149,13 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ xentral as unknown as AdapterDefinition, companiesHouse as unknown as AdapterDefinition, wise as unknown as AdapterDefinition, + calendly as unknown as AdapterDefinition, + discordBot as unknown as AdapterDefinition, mailchimp as unknown as AdapterDefinition, pipedrive as unknown as AdapterDefinition, sendgrid as unknown as AdapterDefinition, sorare as unknown as AdapterDefinition, + telegramBot as unknown as AdapterDefinition, whatsappBusiness as unknown as AdapterDefinition, woocommerce as unknown as AdapterDefinition, wordpress as unknown as AdapterDefinition, diff --git a/packages/backend/src/adapters/intl/calendly.json b/packages/backend/src/adapters/intl/calendly.json new file mode 100644 index 0000000..ce34a72 --- /dev/null +++ b/packages/backend/src/adapters/intl/calendly.json @@ -0,0 +1,253 @@ +{ + "slug": "calendly", + "name": "Calendly", + "description": "Drive Calendly scheduling from any AI agent: list booked events, fetch invitees, query event types, check availability windows, generate single-use scheduling links and cancel meetings. 12 tools, Bearer-token auth.", + "instructions": "This connector uses the Calendly API v2.\n\n**Setup**:\n1. Sign in to https://calendly.com → **Integrations & Apps → API & webhooks → Personal Access Tokens → Create new token**.\n2. Give it a name (e.g. 'AnythingMCP'). The token is shown ONCE, copy it immediately. Personal Access Tokens never expire and inherit your user permissions.\n3. Set `CALENDLY_PERSONAL_ACCESS_TOKEN` to the token.\n\n**Authentication**: Bearer token. Calendly also supports OAuth2 for multi-user apps but Personal Access Tokens are the right fit for single-account agent use.\n\n**The URI model**: every Calendly resource is identified by a full HTTPS URI, NOT a bare ID. For instance a user is `https://api.calendly.com/users/AAAAAAAAAAAAAAAA`, not just `AAAAAAAAAAAAAAAA`. List endpoints expect these URIs as query parameters (e.g. `?user=https%3A%2F%2Fapi.calendly.com%2Fusers%2F...`). Always grab the URI from a prior tool's response — the easiest starting point is `calendly_get_current_user`, whose `resource.uri` is your user URI. Same model for organizations, event types, and scheduled events.\n\n**Pagination**: cursor-based. Each list response has `pagination.next_page_token`. Pass it as `page_token` on the next call.\n\n**Cancelling a meeting**: counter-intuitively uses POST, not DELETE (`POST /scheduled_events/{uuid}/cancellation`). Body accepts a `reason` text that's shown to the invitee. The adapter exposes this as `calendly_cancel_scheduled_event`.\n\n**Availability windows**: `calendly_list_event_type_available_times` returns the open slots between `start_time` and `end_time` for an event type. The span MUST be ≤ 7 days; longer ranges return 400. For multi-week scans, call it once per week.\n\n**Single-use scheduling links**: `calendly_create_single_use_scheduling_link` produces a URL that can be booked ONCE (max_event_count=1) for a specific event type. Useful when an agent wants to invite one specific person without exposing your generic Calendly profile.\n\n**Read-mostly**: Calendly's API is heavier on reads than writes. There's no 'create new event type' or 'create new booking on behalf of someone' endpoint — bookings come from invitees clicking a scheduling link.\n\n**Rate limits**: 100k requests / day per account, with a burst window. On 429, back off.\n\n**Webhooks** out of scope (`/webhook_subscriptions` is supported via API but receiving the payloads needs a hosted endpoint).\n\n**Plan gating**: certain endpoints (organizations, memberships, routing forms) require Teams or Enterprise plans. Personal accounts get 403 — error message identifies the plan required.", + "region": "intl", + "category": "scheduling", + "icon": "calendly", + "docsUrl": "https://developer.calendly.com/api-docs", + "requiredEnvVars": ["CALENDLY_PERSONAL_ACCESS_TOKEN"], + "connector": { + "name": "Calendly v2", + "type": "REST", + "baseUrl": "https://api.calendly.com", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{CALENDLY_PERSONAL_ACCESS_TOKEN}}" + } + }, + "tools": [ + { + "name": "calendly_get_current_user", + "description": "Return the user the personal access token belongs to (whoami). The response's `resource.uri` is THE user URI you'll need to pass to most other tools. Call this first when the agent starts.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/users/me" } + }, + { + "name": "calendly_get_user", + "description": "Get any user by their URI. Use when you have a user URI from a prior response (e.g. an event organizer) and need their full profile.", + "parameters": { + "type": "object", + "properties": { + "userUuid": { "type": "string", "description": "User UUID (the last segment of the user URI)." } + }, + "required": ["userUuid"] + }, + "endpointMapping": { "method": "GET", "path": "/users/{userUuid}" } + }, + { + "name": "calendly_list_event_types", + "description": "List the event types (meeting types: '30-min intro', 'demo call', etc.) for a user or organization. Each event type has a URI, name, slug, duration, kind (solo/group/round_robin), active flag, and scheduling_url.", + "parameters": { + "type": "object", + "properties": { + "user": { "type": "string", "description": "Full user URI: https://api.calendly.com/users/UUID. Mutually exclusive with `organization`." }, + "organization": { "type": "string", "description": "Full organization URI. Mutually exclusive with `user`." }, + "active": { "type": "boolean", "description": "Filter active-only event types. Default returns all." }, + "count": { "type": "integer", "description": "Max per page (default 20, max 100)." }, + "page_token": { "type": "string", "description": "Pagination cursor from prior pagination.next_page_token." }, + "sort": { "type": "string", "description": "Sort by: name:asc, name:desc, position:asc, position:desc." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/event_types", + "queryParams": { + "user": "$user", + "organization": "$organization", + "active": "$active", + "count": "$count", + "page_token": "$page_token", + "sort": "$sort" + } + } + }, + { + "name": "calendly_get_event_type", + "description": "Fetch a single event type's full config including custom questions, location options, color, kind, scheduling_url and pooling_type (for round-robin event types).", + "parameters": { + "type": "object", + "properties": { + "eventTypeUuid": { "type": "string", "description": "Event type UUID (last URI segment)." } + }, + "required": ["eventTypeUuid"] + }, + "endpointMapping": { "method": "GET", "path": "/event_types/{eventTypeUuid}" } + }, + { + "name": "calendly_list_event_type_available_times", + "description": "Return open availability slots for an event type between start_time and end_time. **The window MUST be ≤ 7 days** — Calendly rejects longer ranges with 400. Each slot returns start_time, status, scheduling_url and invitees_remaining (for round-robin).", + "parameters": { + "type": "object", + "properties": { + "event_type": { "type": "string", "description": "Full event type URI: https://api.calendly.com/event_types/UUID." }, + "start_time": { "type": "string", "description": "ISO 8601 datetime, e.g. '2026-06-01T00:00:00Z'. Must be in the future." }, + "end_time": { "type": "string", "description": "ISO 8601 datetime. Must be ≤ 7 days after start_time." } + }, + "required": ["event_type", "start_time", "end_time"] + }, + "endpointMapping": { + "method": "GET", + "path": "/event_type_available_times", + "queryParams": { + "event_type": "$event_type", + "start_time": "$start_time", + "end_time": "$end_time" + } + } + }, + { + "name": "calendly_list_scheduled_events", + "description": "List booked events (meetings) for a user or organization, with optional status / date-range filters. Each event has uri, name, status (active/canceled), start_time, end_time, event_type, location, invitees_counter.", + "parameters": { + "type": "object", + "properties": { + "user": { "type": "string", "description": "User URI. Mutually exclusive with `organization`." }, + "organization": { "type": "string", "description": "Organization URI." }, + "invitee_email": { "type": "string", "description": "Filter to events booked by this invitee email." }, + "status": { "type": "string", "description": "active or canceled. Default returns both." }, + "min_start_time": { "type": "string", "description": "ISO 8601 — events starting at or after this time." }, + "max_start_time": { "type": "string", "description": "ISO 8601 — events starting at or before this time." }, + "count": { "type": "integer", "description": "Max per page (default 20, max 100)." }, + "page_token": { "type": "string", "description": "Pagination cursor." }, + "sort": { "type": "string", "description": "Sort: start_time:asc, start_time:desc." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/scheduled_events", + "queryParams": { + "user": "$user", + "organization": "$organization", + "invitee_email": "$invitee_email", + "status": "$status", + "min_start_time": "$min_start_time", + "max_start_time": "$max_start_time", + "count": "$count", + "page_token": "$page_token", + "sort": "$sort" + } + } + }, + { + "name": "calendly_get_scheduled_event", + "description": "Fetch one scheduled event by UUID. Returns location, meeting_notes_plain, meeting_notes_html, cancel_url, reschedule_url, event_memberships (the host(s)).", + "parameters": { + "type": "object", + "properties": { + "eventUuid": { "type": "string", "description": "Scheduled event UUID." } + }, + "required": ["eventUuid"] + }, + "endpointMapping": { "method": "GET", "path": "/scheduled_events/{eventUuid}" } + }, + { + "name": "calendly_list_scheduled_event_invitees", + "description": "List invitees (the people who booked) on a scheduled event. Each invitee has uri, email, name, status, timezone, questions_and_answers (custom questions they answered), and any payment details.", + "parameters": { + "type": "object", + "properties": { + "eventUuid": { "type": "string", "description": "Scheduled event UUID." }, + "status": { "type": "string", "description": "active or canceled." }, + "email": { "type": "string", "description": "Filter to a specific invitee email." }, + "count": { "type": "integer", "description": "Max per page." }, + "page_token": { "type": "string", "description": "Pagination cursor." } + }, + "required": ["eventUuid"] + }, + "endpointMapping": { + "method": "GET", + "path": "/scheduled_events/{eventUuid}/invitees", + "queryParams": { + "status": "$status", + "email": "$email", + "count": "$count", + "page_token": "$page_token" + } + } + }, + { + "name": "calendly_cancel_scheduled_event", + "description": "Cancel a scheduled event. The invitee receives a cancellation email with the reason. Counter-intuitively uses POST (not DELETE) per Calendly's REST convention.", + "parameters": { + "type": "object", + "properties": { + "eventUuid": { "type": "string", "description": "Scheduled event UUID." }, + "reason": { "type": "string", "description": "Human-readable cancellation reason shown to the invitee (max 1024 chars)." } + }, + "required": ["eventUuid"] + }, + "endpointMapping": { + "method": "POST", + "path": "/scheduled_events/{eventUuid}/cancellation", + "bodyMapping": { + "reason": "$reason" + } + } + }, + { + "name": "calendly_create_single_use_scheduling_link", + "description": "Create a one-time-use booking URL for a specific event type. Once an invitee books through this URL, it can't be reused. Useful when sending an invitation to one specific person without exposing your generic /username profile.", + "parameters": { + "type": "object", + "properties": { + "max_event_count": { "type": "integer", "description": "Always 1 for single-use links (only value Calendly accepts here)." }, + "owner": { "type": "string", "description": "Full event type URI (https://api.calendly.com/event_types/UUID)." }, + "owner_type": { "type": "string", "description": "Always 'EventType'." } + }, + "required": ["max_event_count", "owner", "owner_type"] + }, + "endpointMapping": { + "method": "POST", + "path": "/scheduling_links", + "bodyMapping": { + "max_event_count": "$max_event_count", + "owner": "$owner", + "owner_type": "$owner_type" + } + } + }, + { + "name": "calendly_list_organization_memberships", + "description": "List the members of an organization (Teams/Enterprise plan). Each membership has user URI, role (owner/admin/user), updated_at. Use to map user URIs to display names for the entire team.", + "parameters": { + "type": "object", + "properties": { + "organization": { "type": "string", "description": "Full organization URI." }, + "user": { "type": "string", "description": "Filter to a specific user URI." }, + "email": { "type": "string", "description": "Filter to a specific email." }, + "count": { "type": "integer", "description": "Max per page." }, + "page_token": { "type": "string", "description": "Pagination cursor." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/organization_memberships", + "queryParams": { + "organization": "$organization", + "user": "$user", + "email": "$email", + "count": "$count", + "page_token": "$page_token" + } + } + }, + { + "name": "calendly_get_invitee", + "description": "Fetch a single invitee on a scheduled event by their invitee UUID. Returns full Q&A, no_show data, reschedule_url, cancel_url and timezone.", + "parameters": { + "type": "object", + "properties": { + "eventUuid": { "type": "string", "description": "Scheduled event UUID." }, + "inviteeUuid": { "type": "string", "description": "Invitee UUID." } + }, + "required": ["eventUuid", "inviteeUuid"] + }, + "endpointMapping": { + "method": "GET", + "path": "/scheduled_events/{eventUuid}/invitees/{inviteeUuid}" + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/calendly.live.spec.ts b/packages/backend/src/adapters/intl/calendly.live.spec.ts new file mode 100644 index 0000000..25d3d2d --- /dev/null +++ b/packages/backend/src/adapters/intl/calendly.live.spec.ts @@ -0,0 +1,49 @@ +import * as adapter from './calendly.json'; +import { RestEngine } from '../../connectors/engines/rest.engine'; +import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service'; +import { LoginTokenService } from '../../connectors/engines/login-token.service'; + +/** Live: RUN_CALENDLY_LIVE=1 npx jest src/adapters/intl/calendly.live.spec.ts */ + +interface Tool { name: string; endpointMapping: { method: string; path: string } } +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: Record }; + tools: Tool[]; +}; + +describe('calendly adapter — static spec conformance', () => { + it('uses api.calendly.com base URL', () => { + expect(a.connector.baseUrl).toBe('https://api.calendly.com'); + }); + it('Bearer auth', () => { + expect(a.connector.authType).toBe('BEARER_TOKEN'); + expect(a.connector.authConfig.token).toBe('{{CALENDLY_PERSONAL_ACCESS_TOKEN}}'); + }); + it('cancel uses POST (not DELETE)', () => { + const t = a.tools.find((x) => x.name === 'calendly_cancel_scheduled_event')!; + expect(t.endpointMapping.method).toBe('POST'); + expect(t.endpointMapping.path).toBe('/scheduled_events/{eventUuid}/cancellation'); + }); + it('single-use link uses POST /scheduling_links', () => { + const t = a.tools.find((x) => x.name === 'calendly_create_single_use_scheduling_link')!; + expect(t.endpointMapping.method).toBe('POST'); + expect(t.endpointMapping.path).toBe('/scheduling_links'); + }); +}); + +const maybe = process.env.RUN_CALENDLY_LIVE ? describe : describe.skip; +maybe('calendly adapter — live edge reachability', () => { + const engine = new RestEngine({} as OAuth2TokenService, {} as LoginTokenService); + it('GET /users/me reaches Calendly edge (401)', async () => { + let err: any; + try { + await engine.execute( + { baseUrl: a.connector.baseUrl, authType: 'BEARER_TOKEN', authConfig: { token: 'bogus' } }, + { method: 'GET', path: '/users/me' }, + {}, + ); + } catch (e) { err = e; } + expect(err).toBeDefined(); + expect(err.response?.status).toBe(401); + }, 30000); +}); diff --git a/packages/backend/src/adapters/intl/discord-bot.json b/packages/backend/src/adapters/intl/discord-bot.json new file mode 100644 index 0000000..93ff1b6 --- /dev/null +++ b/packages/backend/src/adapters/intl/discord-bot.json @@ -0,0 +1,250 @@ +{ + "slug": "discord-bot", + "name": "Discord Bot", + "description": "Send messages, embeds, files and reactions to Discord channels and DMs from any AI agent via the Discord REST API. 13 tools, Bot Token auth. Outbound + lightweight channel/guild reads (no gateway / websocket / voice).", + "instructions": "This connector uses the Discord REST API v10 (https://discord.com/developers/docs).\n\n**Setup**:\n1. Open https://discord.com/developers/applications → **New Application** → name it (this becomes the app, not the bot).\n2. Sidebar → **Bot → Reset Token → copy the token**. Treat as a secret — anyone with it can act as the bot.\n3. Sidebar → **Installation** (or **OAuth2 → URL Generator** in older UI): pick scope `bot`, then permissions like Send Messages, Embed Links, Attach Files, Read Message History, Add Reactions, View Channels. Open the generated URL in a browser to **install the bot into a server** (you must be admin or have Manage Server). Repeat per server.\n4. Set `DISCORD_BOT_TOKEN` to the token from step 2.\n\n**Authentication**: header `Authorization: Bot {{DISCORD_BOT_TOKEN}}` — note the literal `Bot ` prefix (NOT `Bearer`). The adapter sets this header via authType=API_KEY with the `Authorization` header name.\n\n**Snowflake IDs**: every channel, guild, user, message in Discord is a numeric snowflake (~18-19 digits, string-typed). Discover them via `discord_bot_list_guilds` → `discord_bot_list_guild_channels` → drill down. In the Discord client, enable **Developer Mode** (User Settings → Advanced) and right-click any object → 'Copy ID' to grab snowflakes manually.\n\n**Channel types**: 0=text, 2=voice, 4=category, 5=announcement, 11=public thread, 12=private thread, 13=stage, 15=forum. Messages can be sent to text (0), announcement (5), threads (11/12), forum posts (15 via thread).\n\n**Sending a message — embeds vs content**:\n- `content`: plain text (Markdown supported, ≤ 2000 chars).\n- `embeds`: rich card with title/description/color/fields/image/timestamp (up to 10 per message, each ≤ 6000 chars total).\nUse embeds for structured data (release notes, alerts, dashboards).\n\n**File attachments**: must be uploaded as multipart in the same request — NOT supported in a single tool call here. Workaround: host the file on a public URL and include it as an embed image or a hyperlink in the content. For attaching the file itself, do the multipart upload externally and pass the resulting message_id.\n\n**Threads vs channels**: threads are child channels of a parent channel; the API treats them like channels (same endpoint `/channels/{id}/messages`). The thread_id IS the channel_id.\n\n**Rate limits**: per-route AND global. Discord returns `X-RateLimit-Remaining` / `X-RateLimit-Reset-After` headers. On 429, wait the `retry_after` seconds. Hard cap: 50 req/sec global per token.\n\n**Permission errors (50001 / 50013)**: bot needs the right server permission in the channel. Adjust the bot's role permissions or grant per-channel overrides.\n\n**DMs to users**: you can't DM a user out of the blue — you must first call `discord_bot_create_dm_channel` with the user_id to open a DM channel, then send to that channel_id. The user must also share at least one server with the bot.\n\n**Inbound / gateway / voice / slash commands**: out of scope here. The Discord WebSocket gateway (for receiving messages, presence, voice) and Interactions endpoints (slash commands) need a long-running process or a registered interactions webhook — separate plan.", + "region": "intl", + "category": "messaging", + "icon": "discord", + "docsUrl": "https://discord.com/developers/docs/reference", + "requiredEnvVars": ["DISCORD_BOT_TOKEN"], + "connector": { + "name": "Discord REST API v10", + "type": "REST", + "baseUrl": "https://discord.com/api/v10", + "authType": "API_KEY", + "authConfig": { + "headerName": "Authorization", + "apiKey": "Bot {{DISCORD_BOT_TOKEN}}" + } + }, + "tools": [ + { + "name": "discord_bot_get_current_user", + "description": "Return the bot's own user object (id, username, discriminator, avatar). Health check + whoami.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/users/@me" } + }, + { + "name": "discord_bot_list_guilds", + "description": "List the servers (guilds) the bot is a member of. Returns id, name, icon, owner flag, permissions bitfield. Bot must be installed in a server to see it here.", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Max 1-200 (default 200)." }, + "before": { "type": "string", "description": "Snowflake ID — return guilds before this ID (pagination)." }, + "after": { "type": "string", "description": "Snowflake ID — return guilds after this ID (pagination)." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/users/@me/guilds", + "queryParams": { "limit": "$limit", "before": "$before", "after": "$after" } + } + }, + { + "name": "discord_bot_get_guild", + "description": "Fetch a single guild by ID. Returns name, icon, splash, owner_id, region, afk_channel, system_channel, features, member_count (if with_counts=true).", + "parameters": { + "type": "object", + "properties": { + "guildId": { "type": "string", "description": "Guild snowflake ID." }, + "with_counts": { "type": "boolean", "description": "If true, include approximate_member_count and approximate_presence_count." } + }, + "required": ["guildId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/guilds/{guildId}", + "queryParams": { "with_counts": "$with_counts" } + } + }, + { + "name": "discord_bot_list_guild_channels", + "description": "List all channels (text, voice, category, threads' parents) in a guild. Use to discover channel_id values to send messages into.", + "parameters": { + "type": "object", + "properties": { + "guildId": { "type": "string", "description": "Guild snowflake ID." } + }, + "required": ["guildId"] + }, + "endpointMapping": { "method": "GET", "path": "/guilds/{guildId}/channels" } + }, + { + "name": "discord_bot_get_channel", + "description": "Fetch a single channel by ID. Returns type, name, topic, parent_id, position, permission_overwrites, last_message_id.", + "parameters": { + "type": "object", + "properties": { + "channelId": { "type": "string", "description": "Channel snowflake." } + }, + "required": ["channelId"] + }, + "endpointMapping": { "method": "GET", "path": "/channels/{channelId}" } + }, + { + "name": "discord_bot_send_message", + "description": "Send a message to a channel or thread. Provide `content` (text) OR `embeds` OR both. For DMs, first open a DM channel with discord_bot_create_dm_channel.", + "parameters": { + "type": "object", + "properties": { + "channelId": { "type": "string", "description": "Channel snowflake (text channel, thread, or DM channel ID)." }, + "content": { "type": "string", "description": "Text content ≤2000 chars. Supports Discord Markdown." }, + "embeds": { "type": "array", "description": "Array of up to 10 embed objects. Each: {title, description, url, color (decimal RGB), timestamp ISO 8601, footer{text,icon_url}, image{url}, thumbnail{url}, author{name,url,icon_url}, fields[{name,value,inline?}]}. Total ≤6000 chars." }, + "tts": { "type": "boolean", "description": "If true, send as text-to-speech (rarely useful)." }, + "allowed_mentions": { "type": "object", "description": "Control which mentions trigger notifications: {parse:['users','roles','everyone'], users:[id], roles:[id], replied_user:bool}. Use {parse:[]} to suppress all pings." }, + "message_reference": { "type": "object", "description": "Reply to an existing message: {message_id, channel_id?, guild_id?, fail_if_not_exists?}." } + }, + "required": ["channelId"] + }, + "endpointMapping": { + "method": "POST", + "path": "/channels/{channelId}/messages", + "bodyMapping": { + "content": "$content", + "embeds": "$embeds", + "tts": "$tts", + "allowed_mentions": "$allowed_mentions", + "message_reference": "$message_reference" + } + } + }, + { + "name": "discord_bot_edit_message", + "description": "Edit a message the bot previously sent. Cannot edit messages from other users.", + "parameters": { + "type": "object", + "properties": { + "channelId": { "type": "string", "description": "Channel snowflake." }, + "messageId": { "type": "string", "description": "Message snowflake to edit." }, + "content": { "type": "string", "description": "New text content (or null to clear)." }, + "embeds": { "type": "array", "description": "New embeds array (or [] to clear)." } + }, + "required": ["channelId", "messageId"] + }, + "endpointMapping": { + "method": "PATCH", + "path": "/channels/{channelId}/messages/{messageId}", + "bodyMapping": { + "content": "$content", + "embeds": "$embeds" + } + } + }, + { + "name": "discord_bot_delete_message", + "description": "Delete a message. Bot needs Manage Messages permission to delete others' messages; can always delete its own.", + "parameters": { + "type": "object", + "properties": { + "channelId": { "type": "string", "description": "Channel snowflake." }, + "messageId": { "type": "string", "description": "Message snowflake to delete." } + }, + "required": ["channelId", "messageId"] + }, + "endpointMapping": { + "method": "DELETE", + "path": "/channels/{channelId}/messages/{messageId}" + } + }, + { + "name": "discord_bot_list_messages", + "description": "Fetch recent messages from a channel. Bot needs Read Message History. Default 50; max 100. Use `before` for older history (paginate backwards).", + "parameters": { + "type": "object", + "properties": { + "channelId": { "type": "string", "description": "Channel snowflake." }, + "limit": { "type": "integer", "description": "Max messages 1-100 (default 50)." }, + "before": { "type": "string", "description": "Snowflake — return messages with ID less than this (older)." }, + "after": { "type": "string", "description": "Snowflake — return messages with ID greater than this (newer)." }, + "around": { "type": "string", "description": "Snowflake — return messages centered around this ID." } + }, + "required": ["channelId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/channels/{channelId}/messages", + "queryParams": { + "limit": "$limit", + "before": "$before", + "after": "$after", + "around": "$around" + } + } + }, + { + "name": "discord_bot_add_reaction", + "description": "Add a reaction emoji to a message as the bot. For unicode emoji pass the literal char (e.g. '👍'). For custom guild emoji pass 'name:id'.", + "parameters": { + "type": "object", + "properties": { + "channelId": { "type": "string", "description": "Channel snowflake." }, + "messageId": { "type": "string", "description": "Message snowflake." }, + "emoji": { "type": "string", "description": "URL-encoded emoji. Unicode like '%F0%9F%91%8D' for 👍, or 'name:id' for custom emoji." } + }, + "required": ["channelId", "messageId", "emoji"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/channels/{channelId}/messages/{messageId}/reactions/{emoji}/@me" + } + }, + { + "name": "discord_bot_create_dm_channel", + "description": "Open a DM channel with a user. Returns a channel object with `id` — use that channel_id in discord_bot_send_message to actually DM them. The user must share at least one server with the bot.", + "parameters": { + "type": "object", + "properties": { + "recipient_id": { "type": "string", "description": "User snowflake to DM." } + }, + "required": ["recipient_id"] + }, + "endpointMapping": { + "method": "POST", + "path": "/users/@me/channels", + "bodyMapping": { + "recipient_id": "$recipient_id" + } + } + }, + { + "name": "discord_bot_create_thread_from_message", + "description": "Start a public thread anchored to an existing message. Useful for spawning discussion threads from bot announcements.", + "parameters": { + "type": "object", + "properties": { + "channelId": { "type": "string", "description": "Parent channel snowflake." }, + "messageId": { "type": "string", "description": "Message to anchor the thread to." }, + "name": { "type": "string", "description": "Thread name (1-100 chars)." }, + "auto_archive_duration": { "type": "integer", "description": "Auto-archive minutes: 60, 1440, 4320, or 10080." } + }, + "required": ["channelId", "messageId", "name"] + }, + "endpointMapping": { + "method": "POST", + "path": "/channels/{channelId}/messages/{messageId}/threads", + "bodyMapping": { + "name": "$name", + "auto_archive_duration": "$auto_archive_duration" + } + } + }, + { + "name": "discord_bot_get_guild_member", + "description": "Fetch a guild member by user_id. Returns user object, nick, roles[], joined_at, premium_since, permissions in the guild.", + "parameters": { + "type": "object", + "properties": { + "guildId": { "type": "string", "description": "Guild snowflake." }, + "userId": { "type": "string", "description": "User snowflake." } + }, + "required": ["guildId", "userId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/guilds/{guildId}/members/{userId}" + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/discord-bot.live.spec.ts b/packages/backend/src/adapters/intl/discord-bot.live.spec.ts new file mode 100644 index 0000000..67837b5 --- /dev/null +++ b/packages/backend/src/adapters/intl/discord-bot.live.spec.ts @@ -0,0 +1,58 @@ +import * as adapter from './discord-bot.json'; +import { RestEngine } from '../../connectors/engines/rest.engine'; +import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service'; +import { LoginTokenService } from '../../connectors/engines/login-token.service'; + +/** Live: RUN_DISCORD_BOT_LIVE=1 npx jest src/adapters/intl/discord-bot.live.spec.ts */ + +interface Tool { name: string; endpointMapping: { method: string; path: string } } +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: Record }; + tools: Tool[]; +}; + +describe('discord-bot adapter — static spec conformance', () => { + it('uses discord.com/api/v10', () => { + expect(a.connector.baseUrl).toBe('https://discord.com/api/v10'); + }); + + it('authenticates with Bot prefix (NOT Bearer) — Discord-specific', () => { + expect(a.connector.authType).toBe('API_KEY'); + expect(a.connector.authConfig.headerName).toBe('Authorization'); + expect(a.connector.authConfig.apiKey).toBe('Bot {{DISCORD_BOT_TOKEN}}'); + }); + + it('send-message uses POST /channels/{channelId}/messages', () => { + const t = a.tools.find((x) => x.name === 'discord_bot_send_message')!; + expect(t.endpointMapping.method).toBe('POST'); + expect(t.endpointMapping.path).toBe('/channels/{channelId}/messages'); + }); + + it('add-reaction uses PUT (idempotent — reacting twice with same emoji is a no-op)', () => { + const t = a.tools.find((x) => x.name === 'discord_bot_add_reaction')!; + expect(t.endpointMapping.method).toBe('PUT'); + expect(t.endpointMapping.path).toContain('/reactions/{emoji}/@me'); + }); +}); + +const maybe = process.env.RUN_DISCORD_BOT_LIVE ? describe : describe.skip; +maybe('discord-bot adapter — live edge reachability', () => { + const engine = new RestEngine({} as OAuth2TokenService, {} as LoginTokenService); + + it('GET /users/@me reaches Discord edge (401 with bogus token)', async () => { + let err: any; + try { + await engine.execute( + { + baseUrl: a.connector.baseUrl, + authType: 'API_KEY', + authConfig: { headerName: 'Authorization', apiKey: 'Bot bogus-token-for-validation' }, + }, + { method: 'GET', path: '/users/@me' }, + {}, + ); + } catch (e) { err = e; } + expect(err).toBeDefined(); + expect(err.response?.status).toBe(401); + }, 30000); +}); diff --git a/packages/backend/src/adapters/intl/telegram-bot.json b/packages/backend/src/adapters/intl/telegram-bot.json new file mode 100644 index 0000000..2e5d2fc --- /dev/null +++ b/packages/backend/src/adapters/intl/telegram-bot.json @@ -0,0 +1,345 @@ +{ + "slug": "telegram-bot", + "name": "Telegram Bot", + "description": "Send Telegram messages, photos, documents, audio, location and polls from any AI agent via the Telegram Bot API. 14 tools, bot-token auth. Outbound + lightweight read of recent updates (no long-polling).", + "instructions": "This connector uses the official Telegram Bot API (https://core.telegram.org/bots/api).\n\n**Setup**:\n1. In Telegram, open chat with **@BotFather** → `/newbot` → pick a display name and a username ending in `bot`.\n2. BotFather replies with an HTTP API token like `123456789:ABCDEFghijklmnopqrstuvwxyz`. **Treat as a secret** — anyone with it can send as your bot.\n3. Set `TELEGRAM_BOT_TOKEN` to the full token.\n\n**Authentication model** (Telegram-specific): the bot token goes in the URL path, NOT in a header. The base URL is `https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}` and every endpoint is appended as `/methodName`. The adapter substitutes the token via the `{{TELEGRAM_BOT_TOKEN}}` env-var template in `baseUrl`.\n\n**chat_id model**: every Telegram chat (user, group, channel) has a numeric `chat_id`. For groups and channels the bot must be ADDED first; for users, the user must have sent a `/start` to the bot at least once (Telegram's privacy rule). Once you have a chat_id (extract it from `getUpdates`), pin it.\n\n**Channels**: for **public channels** you can also pass `@channelusername` (with the `@`) instead of a numeric chat_id. Private channels REQUIRE the numeric ID, which always starts with `-100`.\n\n**Markdown vs HTML**: messages support 3 parse modes — `MarkdownV2` (current, escape special chars), `HTML` (``, ``, ``), or no formatting. Telegram's `MarkdownV2` requires escaping `_ * [ ] ( ) ~ ` > # + - = | { } . !` — agents that don't escape will get 400 'can't parse entities'. The HTML mode is easier for agents to compose safely.\n\n**File send model**: media tools (sendPhoto, sendDocument, sendAudio, etc.) accept the media as:\n - **HTTPS URL** (recommended on AnythingMCP): Telegram fetches it server-side. URL must be ≤ 5MB for photos, ≤ 20MB for documents via URL.\n - **file_id**: a string returned from a previous send (caching — reuse the same file_id to re-send the same content without re-uploading).\n - **multipart binary**: NOT supported by this connector in a single tool call. Pre-upload elsewhere and pass the resulting URL or file_id.\n\n**getUpdates vs webhooks**: this connector exposes `telegram_get_updates` (long-polling pull) for one-shot reading of recent inbound messages. For continuous inbound stream (chatbot), use Telegram's webhook (setWebhook) and host the receiver yourself — outside the MCP request/response model.\n\n**Rate limits**: 30 messages/sec to different chats, 1 msg/sec to the same chat, 20 messages/minute to the same group. On 429, honor the `retry_after` in the response.\n\n**Out of scope**: payments, inline mode, callback query answers (these usually require a long-running webhook), passport, game scores, sticker management.", + "region": "intl", + "category": "messaging", + "icon": "telegram", + "docsUrl": "https://core.telegram.org/bots/api", + "requiredEnvVars": ["TELEGRAM_BOT_TOKEN"], + "connector": { + "name": "Telegram Bot API", + "type": "REST", + "baseUrl": "https://api.telegram.org/bot{{TELEGRAM_BOT_TOKEN}}", + "authType": "NONE", + "authConfig": {} + }, + "tools": [ + { + "name": "telegram_bot_get_me", + "description": "Return the bot's own user info: id, username, first_name, can_join_groups, can_read_all_group_messages, supports_inline_queries. Health check + whoami in one call.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/getMe" } + }, + { + "name": "telegram_bot_send_message", + "description": "Send a text message to a chat. Required: chat_id + text. Use parse_mode='HTML' for safe formatting (``, ``, ``, ``). reply_to_message_id quotes a prior message.", + "parameters": { + "type": "object", + "properties": { + "chat_id": { "type": "string", "description": "Numeric chat_id OR '@channelusername' for public channels. For users, they must have started the bot first." }, + "text": { "type": "string", "description": "Message text, max 4096 chars. For longer, split into multiple sends." }, + "parse_mode": { "type": "string", "description": "MarkdownV2, HTML, or omit for no formatting. HTML is safer for agents." }, + "disable_web_page_preview": { "type": "boolean", "description": "If true, no link preview is shown for URLs in the text." }, + "disable_notification": { "type": "boolean", "description": "If true, send silently (no push notification sound)." }, + "reply_to_message_id": { "type": "integer", "description": "Quote a prior message by its message_id." }, + "reply_markup": { "type": "object", "description": "Inline keyboard or reply keyboard markup. See Telegram docs for InlineKeyboardMarkup shape." } + }, + "required": ["chat_id", "text"] + }, + "endpointMapping": { + "method": "POST", + "path": "/sendMessage", + "bodyMapping": { + "chat_id": "$chat_id", + "text": "$text", + "parse_mode": "$parse_mode", + "disable_web_page_preview": "$disable_web_page_preview", + "disable_notification": "$disable_notification", + "reply_to_message_id": "$reply_to_message_id", + "reply_markup": "$reply_markup" + } + } + }, + { + "name": "telegram_bot_send_photo", + "description": "Send a photo (JPG/PNG, ≤5MB via URL) by HTTPS URL or by reusable file_id. Optional caption supports parse_mode.", + "parameters": { + "type": "object", + "properties": { + "chat_id": { "type": "string", "description": "Chat ID or @channelusername." }, + "photo": { "type": "string", "description": "Either a public HTTPS URL OR a file_id from a prior send." }, + "caption": { "type": "string", "description": "Caption ≤1024 chars." }, + "parse_mode": { "type": "string", "description": "MarkdownV2, HTML, or omit." }, + "disable_notification": { "type": "boolean", "description": "Silent send." }, + "reply_to_message_id": { "type": "integer", "description": "Reply target message_id." } + }, + "required": ["chat_id", "photo"] + }, + "endpointMapping": { + "method": "POST", + "path": "/sendPhoto", + "bodyMapping": { + "chat_id": "$chat_id", + "photo": "$photo", + "caption": "$caption", + "parse_mode": "$parse_mode", + "disable_notification": "$disable_notification", + "reply_to_message_id": "$reply_to_message_id" + } + } + }, + { + "name": "telegram_bot_send_document", + "description": "Send a document (any type, ≤20MB via URL) by HTTPS URL or file_id. Use for PDFs, spreadsheets, code, etc.", + "parameters": { + "type": "object", + "properties": { + "chat_id": { "type": "string", "description": "Chat ID or @channelusername." }, + "document": { "type": "string", "description": "Public HTTPS URL or file_id." }, + "caption": { "type": "string", "description": "Caption ≤1024 chars." }, + "parse_mode": { "type": "string", "description": "MarkdownV2, HTML, or omit." }, + "disable_notification": { "type": "boolean", "description": "Silent." }, + "reply_to_message_id": { "type": "integer", "description": "Reply target." } + }, + "required": ["chat_id", "document"] + }, + "endpointMapping": { + "method": "POST", + "path": "/sendDocument", + "bodyMapping": { + "chat_id": "$chat_id", + "document": "$document", + "caption": "$caption", + "parse_mode": "$parse_mode", + "disable_notification": "$disable_notification", + "reply_to_message_id": "$reply_to_message_id" + } + } + }, + { + "name": "telegram_bot_send_audio", + "description": "Send an audio file (MP3, M4A, ≤20MB via URL). Telegram displays it with a music player + duration + title. For voice notes use sendVoice instead.", + "parameters": { + "type": "object", + "properties": { + "chat_id": { "type": "string", "description": "Chat ID or @channelusername." }, + "audio": { "type": "string", "description": "HTTPS URL or file_id." }, + "caption": { "type": "string", "description": "Optional caption." }, + "duration": { "type": "integer", "description": "Duration in seconds (improves UX)." }, + "performer": { "type": "string", "description": "Performer/artist name." }, + "title": { "type": "string", "description": "Track title." }, + "disable_notification": { "type": "boolean", "description": "Silent." } + }, + "required": ["chat_id", "audio"] + }, + "endpointMapping": { + "method": "POST", + "path": "/sendAudio", + "bodyMapping": { + "chat_id": "$chat_id", + "audio": "$audio", + "caption": "$caption", + "duration": "$duration", + "performer": "$performer", + "title": "$title", + "disable_notification": "$disable_notification" + } + } + }, + { + "name": "telegram_bot_send_voice", + "description": "Send a voice note (OGG-Opus encoded). Telegram renders it as a microphone bubble with waveform — the canonical 'voice memo' format. Other audio codecs use sendAudio instead.", + "parameters": { + "type": "object", + "properties": { + "chat_id": { "type": "string", "description": "Chat ID." }, + "voice": { "type": "string", "description": "HTTPS URL of an OGG-Opus file OR a file_id." }, + "caption": { "type": "string", "description": "Optional caption." }, + "duration": { "type": "integer", "description": "Duration in seconds." } + }, + "required": ["chat_id", "voice"] + }, + "endpointMapping": { + "method": "POST", + "path": "/sendVoice", + "bodyMapping": { + "chat_id": "$chat_id", + "voice": "$voice", + "caption": "$caption", + "duration": "$duration" + } + } + }, + { + "name": "telegram_bot_send_video", + "description": "Send a video (MP4, H.264, AAC, ≤20MB via URL). Telegram streams it inline.", + "parameters": { + "type": "object", + "properties": { + "chat_id": { "type": "string", "description": "Chat ID." }, + "video": { "type": "string", "description": "HTTPS URL of MP4 or file_id." }, + "caption": { "type": "string", "description": "Caption ≤1024 chars." }, + "duration": { "type": "integer", "description": "Duration seconds." }, + "width": { "type": "integer", "description": "Width px." }, + "height": { "type": "integer", "description": "Height px." }, + "supports_streaming": { "type": "boolean", "description": "If true, video is streamable." }, + "parse_mode": { "type": "string", "description": "Markup mode for caption." } + }, + "required": ["chat_id", "video"] + }, + "endpointMapping": { + "method": "POST", + "path": "/sendVideo", + "bodyMapping": { + "chat_id": "$chat_id", + "video": "$video", + "caption": "$caption", + "duration": "$duration", + "width": "$width", + "height": "$height", + "supports_streaming": "$supports_streaming", + "parse_mode": "$parse_mode" + } + } + }, + { + "name": "telegram_bot_send_location", + "description": "Send a location point. Optional `live_period` (60-86400 sec) makes the location auto-update in real time for that duration.", + "parameters": { + "type": "object", + "properties": { + "chat_id": { "type": "string", "description": "Chat ID." }, + "latitude": { "type": "number", "description": "Latitude decimal degrees." }, + "longitude": { "type": "number", "description": "Longitude decimal degrees." }, + "live_period": { "type": "integer", "description": "Seconds the location stays live (60-86400). Omit for static." }, + "horizontal_accuracy": { "type": "number", "description": "Accuracy in meters (0-1500)." } + }, + "required": ["chat_id", "latitude", "longitude"] + }, + "endpointMapping": { + "method": "POST", + "path": "/sendLocation", + "bodyMapping": { + "chat_id": "$chat_id", + "latitude": "$latitude", + "longitude": "$longitude", + "live_period": "$live_period", + "horizontal_accuracy": "$horizontal_accuracy" + } + } + }, + { + "name": "telegram_bot_send_poll", + "description": "Send a poll (regular or quiz). For a quiz, set type='quiz' and correct_option_id (zero-based). Anonymous unless is_anonymous=false. Closes after open_period seconds or manually with stopPoll.", + "parameters": { + "type": "object", + "properties": { + "chat_id": { "type": "string", "description": "Chat ID." }, + "question": { "type": "string", "description": "Poll question (1-300 chars)." }, + "options": { "type": "array", "description": "Array of 2-10 string options." }, + "type": { "type": "string", "description": "regular or quiz." }, + "is_anonymous": { "type": "boolean", "description": "Default true." }, + "allows_multiple_answers": { "type": "boolean", "description": "Only for type=regular." }, + "correct_option_id": { "type": "integer", "description": "0-based index — required for type=quiz." }, + "explanation": { "type": "string", "description": "Explanation shown when a quiz answer is wrong (≤200 chars)." }, + "open_period": { "type": "integer", "description": "Seconds before the poll auto-closes (5-600)." } + }, + "required": ["chat_id", "question", "options"] + }, + "endpointMapping": { + "method": "POST", + "path": "/sendPoll", + "bodyMapping": { + "chat_id": "$chat_id", + "question": "$question", + "options": "$options", + "type": "$type", + "is_anonymous": "$is_anonymous", + "allows_multiple_answers": "$allows_multiple_answers", + "correct_option_id": "$correct_option_id", + "explanation": "$explanation", + "open_period": "$open_period" + } + } + }, + { + "name": "telegram_bot_edit_message_text", + "description": "Edit the text of a previously-sent message (your bot's own messages only). Use to refresh status messages, fix typos, or update interactive UIs without sending a new bubble.", + "parameters": { + "type": "object", + "properties": { + "chat_id": { "type": "string", "description": "Chat ID containing the message." }, + "message_id": { "type": "integer", "description": "message_id of the message to edit." }, + "text": { "type": "string", "description": "New text." }, + "parse_mode": { "type": "string", "description": "MarkdownV2 / HTML." }, + "disable_web_page_preview": { "type": "boolean", "description": "Hide link preview." }, + "reply_markup": { "type": "object", "description": "New inline keyboard (or omit to keep current)." } + }, + "required": ["chat_id", "message_id", "text"] + }, + "endpointMapping": { + "method": "POST", + "path": "/editMessageText", + "bodyMapping": { + "chat_id": "$chat_id", + "message_id": "$message_id", + "text": "$text", + "parse_mode": "$parse_mode", + "disable_web_page_preview": "$disable_web_page_preview", + "reply_markup": "$reply_markup" + } + } + }, + { + "name": "telegram_bot_delete_message", + "description": "Delete a message the bot sent (within ~48 hours for groups, anytime for own DMs). Bots cannot delete arbitrary user messages without group-admin rights.", + "parameters": { + "type": "object", + "properties": { + "chat_id": { "type": "string", "description": "Chat ID." }, + "message_id": { "type": "integer", "description": "message_id to delete." } + }, + "required": ["chat_id", "message_id"] + }, + "endpointMapping": { + "method": "POST", + "path": "/deleteMessage", + "bodyMapping": { + "chat_id": "$chat_id", + "message_id": "$message_id" + } + } + }, + { + "name": "telegram_bot_get_chat", + "description": "Fetch chat info: title, type (private/group/supergroup/channel), photo, description, invite_link, member_count, permissions.", + "parameters": { + "type": "object", + "properties": { + "chat_id": { "type": "string", "description": "Chat ID." } + }, + "required": ["chat_id"] + }, + "endpointMapping": { + "method": "POST", + "path": "/getChat", + "bodyMapping": { "chat_id": "$chat_id" } + } + }, + { + "name": "telegram_bot_get_updates", + "description": "Long-poll recent inbound messages (one-shot — pulls all updates since the last `offset+1`). For continuous inbound, use webhooks instead. Telegram queues updates for 24 hours if not consumed; consuming with this tool clears them.", + "parameters": { + "type": "object", + "properties": { + "offset": { "type": "integer", "description": "Return updates with update_id >= this value. Pass last_seen_id+1 to ack prior batch." }, + "limit": { "type": "integer", "description": "Max updates 1-100 (default 100)." }, + "timeout": { "type": "integer", "description": "Long-poll wait in seconds 0-50 (default 0 = short poll). Don't use long timeouts in a request/response MCP." }, + "allowed_updates": { "type": "array", "description": "Update types to receive, e.g. ['message','callback_query']. Omit for all except chat_member." } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/getUpdates", + "bodyMapping": { + "offset": "$offset", + "limit": "$limit", + "timeout": "$timeout", + "allowed_updates": "$allowed_updates" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/telegram-bot.live.spec.ts b/packages/backend/src/adapters/intl/telegram-bot.live.spec.ts new file mode 100644 index 0000000..13a0f03 --- /dev/null +++ b/packages/backend/src/adapters/intl/telegram-bot.live.spec.ts @@ -0,0 +1,58 @@ +import * as adapter from './telegram-bot.json'; +import { RestEngine } from '../../connectors/engines/rest.engine'; +import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service'; +import { LoginTokenService } from '../../connectors/engines/login-token.service'; + +/** Live: RUN_TELEGRAM_BOT_LIVE=1 npx jest src/adapters/intl/telegram-bot.live.spec.ts */ + +interface Tool { name: string; endpointMapping: { method: string; path: string } } +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string }; + tools: Tool[]; +}; + +describe('telegram-bot adapter — static spec conformance', () => { + it('bot token lives in the URL path (Telegram-specific)', () => { + expect(a.connector.baseUrl).toBe('https://api.telegram.org/bot{{TELEGRAM_BOT_TOKEN}}'); + }); + + it('uses authType NONE since token is path-based, not header-based', () => { + expect(a.connector.authType).toBe('NONE'); + }); + + it('all tools are POST except getMe (which is GET)', () => { + for (const t of a.tools) { + if (t.name === 'telegram_bot_get_me') { + expect(t.endpointMapping.method).toBe('GET'); + } else { + expect(t.endpointMapping.method).toBe('POST'); + } + } + }); + + it('voice notes go to sendVoice (audio bubble), audio files to sendAudio (music player)', () => { + const v = a.tools.find((x) => x.name === 'telegram_bot_send_voice')!; + expect(v.endpointMapping.path).toBe('/sendVoice'); + const au = a.tools.find((x) => x.name === 'telegram_bot_send_audio')!; + expect(au.endpointMapping.path).toBe('/sendAudio'); + }); +}); + +const maybe = process.env.RUN_TELEGRAM_BOT_LIVE ? describe : describe.skip; +maybe('telegram-bot adapter — live edge reachability', () => { + const engine = new RestEngine({} as OAuth2TokenService, {} as LoginTokenService); + + it('GET /bot{bogus}/getMe reaches Telegram edge (401 Unauthorized)', async () => { + let err: any; + try { + await engine.execute( + { baseUrl: 'https://api.telegram.org/bot123:bogus', authType: 'NONE' }, + { method: 'GET', path: '/getMe' }, + {}, + ); + } catch (e) { err = e; } + expect(err).toBeDefined(); + expect(err.response?.status).toBe(401); + expect(err.response?.data?.description).toMatch(/Unauthorized/i); + }, 30000); +}); From 3f58be44be076f5f44b8a536d1e362370ab3793e Mon Sep 17 00:00:00 2001 From: Matteo Date: Wed, 20 May 2026 12:15:33 +0200 Subject: [PATCH 04/19] connectors: add Zendesk, Typeform, LemonSqueezy, Klaviyo adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finishes the verified Top 10 batch (no vendor-official MCP server exists yet for these as of audit). - Zendesk Support v2: 18 tools — tickets CRUD with comment envelope, users, organizations, groups, views, macros, search, side-loading. BASIC_AUTH with email/token convention. Subdomain in baseUrl. - Typeform: 10 tools — list/get forms, list responses (cursor and date-range pagination), delete responses (GDPR), workspaces, insights, themes. Personal Token Bearer. Read-focused since the form-builder write side is rarely worth automating. - Lemon Squeezy v1: 14 tools — stores, products, variants, customers, orders, subscriptions (with cancel-at-period-end DELETE), license keys, discounts, payouts. JSON:API spec with required Accept header set per endpoint, side-loading via include=. Bearer auth. - Klaviyo (revision 2024-10-15): 14 tools — accounts, profiles CRUD, lists, subscribe with double-opt-in respect, segments, events tracking (the heart of Klaviyo flows), metrics, campaigns, flows. Custom 'Klaviyo-API-Key' auth prefix (not Bearer). All tools pin the required revision header. Catalog: 50 adapters total. Validator clean. --- packages/backend/src/adapters/catalog.ts | 8 + .../backend/src/adapters/intl/klaviyo.json | 303 ++++++++++++++++ .../src/adapters/intl/klaviyo.live.spec.ts | 54 +++ .../src/adapters/intl/lemonsqueezy.json | 323 +++++++++++++++++ .../adapters/intl/lemonsqueezy.live.spec.ts | 45 +++ .../backend/src/adapters/intl/typeform.json | 187 ++++++++++ .../src/adapters/intl/typeform.live.spec.ts | 44 +++ .../backend/src/adapters/intl/zendesk.json | 329 ++++++++++++++++++ .../src/adapters/intl/zendesk.live.spec.ts | 63 ++++ 9 files changed, 1356 insertions(+) create mode 100644 packages/backend/src/adapters/intl/klaviyo.json create mode 100644 packages/backend/src/adapters/intl/klaviyo.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/lemonsqueezy.json create mode 100644 packages/backend/src/adapters/intl/lemonsqueezy.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/typeform.json create mode 100644 packages/backend/src/adapters/intl/typeform.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/zendesk.json create mode 100644 packages/backend/src/adapters/intl/zendesk.live.spec.ts diff --git a/packages/backend/src/adapters/catalog.ts b/packages/backend/src/adapters/catalog.ts index 1bd34ee..5831704 100644 --- a/packages/backend/src/adapters/catalog.ts +++ b/packages/backend/src/adapters/catalog.ts @@ -33,14 +33,18 @@ import * as companiesHouse from './gb/companies-house.json'; import * as wise from './gb/wise.json'; import * as calendly from './intl/calendly.json'; import * as discordBot from './intl/discord-bot.json'; +import * as klaviyo from './intl/klaviyo.json'; +import * as lemonsqueezy from './intl/lemonsqueezy.json'; import * as mailchimp from './intl/mailchimp.json'; import * as pipedrive from './intl/pipedrive.json'; import * as sendgrid from './intl/sendgrid.json'; import * as sorare from './intl/sorare.json'; import * as telegramBot from './intl/telegram-bot.json'; +import * as typeform from './intl/typeform.json'; import * as whatsappBusiness from './intl/whatsapp-business.json'; import * as woocommerce from './intl/woocommerce.json'; import * as wordpress from './intl/wordpress.json'; +import * as zendesk from './intl/zendesk.json'; import * as mercadoLibre from './br/mercado-libre.json'; import * as razorpay from './in/razorpay.json'; import * as lineMessaging from './jp/line-messaging.json'; @@ -151,14 +155,18 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ wise as unknown as AdapterDefinition, calendly as unknown as AdapterDefinition, discordBot as unknown as AdapterDefinition, + klaviyo as unknown as AdapterDefinition, + lemonsqueezy as unknown as AdapterDefinition, mailchimp as unknown as AdapterDefinition, pipedrive as unknown as AdapterDefinition, sendgrid as unknown as AdapterDefinition, sorare as unknown as AdapterDefinition, telegramBot as unknown as AdapterDefinition, + typeform as unknown as AdapterDefinition, whatsappBusiness as unknown as AdapterDefinition, woocommerce as unknown as AdapterDefinition, wordpress as unknown as AdapterDefinition, + zendesk as unknown as AdapterDefinition, mercadoLibre as unknown as AdapterDefinition, razorpay as unknown as AdapterDefinition, lineMessaging as unknown as AdapterDefinition, diff --git a/packages/backend/src/adapters/intl/klaviyo.json b/packages/backend/src/adapters/intl/klaviyo.json new file mode 100644 index 0000000..3d9ac5f --- /dev/null +++ b/packages/backend/src/adapters/intl/klaviyo.json @@ -0,0 +1,303 @@ +{ + "slug": "klaviyo", + "name": "Klaviyo", + "description": "Drive Klaviyo (e-commerce email + SMS) from any AI agent: profiles, lists, segments, events tracking, flows, campaigns, catalogs. 14 tools targeting the modern 2024+ API.", + "instructions": "This connector uses the Klaviyo REST API revision **2024-10-15** (current stable).\n\n**Setup**:\n1. Sign in to Klaviyo → bottom-left avatar → **Settings → API Keys → Create Private API Key**.\n2. Set scopes — at minimum: Profiles (Full), Lists (Full), Segments (Read), Events (Full), Catalogs (Read), Campaigns (Read), Flows (Read).\n3. Copy the key. It's prefixed with `pk_`. Set `KLAVIYO_PRIVATE_API_KEY`.\n\n**Authentication**: `Authorization: Klaviyo-API-Key ${KLAVIYO_PRIVATE_API_KEY}` (the literal prefix is `Klaviyo-API-Key`, NOT `Bearer`). The adapter sets this via API_KEY + custom apiKey value.\n\n**Revision header**: every request MUST include `revision: 2024-10-15`. The adapter pins this in `connector.headers`. If you upgrade revision, expect breaking changes — read https://developers.klaviyo.com/en/docs/api_versioning_and_deprecation_policy.\n\n**JSON:API spec**: Klaviyo follows JSON:API for resource shape. Every response: `{data: {id, type, attributes, relationships}, links, meta}`. Use `?include=` (comma-separated) to side-load related resources in one call.\n\n**Filtering syntax**: Klaviyo uses a custom filter DSL on most list endpoints. Example: `?filter=equals(properties,{'$source':'newsletter'})` or `?filter=greater-than(created,2025-01-01T00:00:00Z)`. The adapter exposes common filters as explicit query params.\n\n**Profile model**: Klaviyo's central object. Each profile has `email`, `phone_number`, `external_id`, plus arbitrary `properties` (custom fields). Profiles are upserted by email/phone/external_id — you cannot have duplicates on the same identifier.\n\n**Events**: the heart of Klaviyo's behavioral targeting. Send events via `klaviyo_create_event` with a profile identifier + a metric name (e.g. 'Placed Order') + properties. Klaviyo automatically creates the metric if it doesn't exist. Events drive flows (triggered automations) — sending the right events at the right time is 80% of Klaviyo work.\n\n**Pagination**: cursor-based via `page[cursor]` (response includes `links.next` with the cursor). `page[size]` is typically 20 default, 100 max.\n\n**Lists vs Segments**: lists are static (manually-managed subscribers). Segments are dynamic queries (recompute every few minutes). Use lists for opt-in newsletters, segments for behavioral cohorts ('opened a campaign in last 30 days').\n\n**Rate limits**: vary per endpoint. Profile reads: 75/sec burst, 700/min steady. Profile writes: 150/min. Klaviyo returns `429` with `Retry-After` — honor it.\n\n**Webhooks** out of scope (use Klaviyo's UI to configure event webhooks to your hosted endpoint).\n\n**Out of scope here**: sending individual emails via the legacy track/identify endpoints (use the Events API + flows instead), template editing, image library, reviews, the older v1/v2 REST API.", + "region": "intl", + "category": "marketing-automation", + "icon": "klaviyo", + "docsUrl": "https://developers.klaviyo.com/en/reference/api_overview", + "requiredEnvVars": ["KLAVIYO_PRIVATE_API_KEY"], + "connector": { + "name": "Klaviyo API (2024-10-15)", + "type": "REST", + "baseUrl": "https://a.klaviyo.com/api", + "authType": "API_KEY", + "authConfig": { + "headerName": "Authorization", + "apiKey": "Klaviyo-API-Key {{KLAVIYO_PRIVATE_API_KEY}}" + } + }, + "tools": [ + { + "name": "klaviyo_get_accounts", + "description": "Return account info: id, name, contact_info, industry, locale, timezone, public_api_key (for client-side tracking). Health check + identifies which Klaviyo account the key is for.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { + "method": "GET", + "path": "/accounts", + "headers": { "revision": "2024-10-15", "Accept": "application/vnd.api+json" } + } + }, + { + "name": "klaviyo_list_profiles", + "description": "List profiles (paginated). Supports filtering by email, phone_number, external_id, created/updated date.", + "parameters": { + "type": "object", + "properties": { + "filter": { "type": "string", "description": "Klaviyo filter DSL, e.g. equals(email,'a@b.com') or greater-than(updated,2024-01-01T00:00:00Z)." }, + "fields_profile": { "type": "string", "description": "Comma-separated attributes to return (sparse fieldsets)." }, + "include": { "type": "string", "description": "Side-load: lists, segments." }, + "sort": { "type": "string", "description": "Sort by created, updated, email, etc. Prefix with - for desc." }, + "page_cursor": { "type": "string", "description": "Pagination cursor (from prior links.next)." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/profiles", + "headers": { "revision": "2024-10-15", "Accept": "application/vnd.api+json" }, + "queryParams": { + "filter": "$filter", + "fields[profile]": "$fields_profile", + "include": "$include", + "sort": "$sort", + "page[cursor]": "$page_cursor" + } + } + }, + { + "name": "klaviyo_get_profile", + "description": "Fetch one profile by ID. Returns email, phone_number, external_id, first/last_name, organization, properties (custom fields), created, updated, last_event_date.", + "parameters": { + "type": "object", + "properties": { + "profileId": { "type": "string", "description": "Klaviyo profile ID (starts with '01...')." }, + "include": { "type": "string", "description": "Side-load: lists, segments." } + }, + "required": ["profileId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/profiles/{profileId}", + "headers": { "revision": "2024-10-15", "Accept": "application/vnd.api+json" }, + "queryParams": { "include": "$include" } + } + }, + { + "name": "klaviyo_create_or_update_profile", + "description": "Upsert a profile by email, phone_number, or external_id (in that priority). Returns the profile (status 201 if new, 200 if updated). Use this for onboarding new subscribers.", + "parameters": { + "type": "object", + "properties": { + "data": { + "type": "object", + "description": "{type: 'profile', attributes: {email?, phone_number?, external_id?, first_name?, last_name?, organization?, title?, image?, location:{address1,city,country,...}, properties:{custom...}}}. Wrap inside {data:{...}}. At least one identifier (email/phone/external_id) required." + } + }, + "required": ["data"] + }, + "endpointMapping": { + "method": "POST", + "path": "/profile-import", + "headers": { "revision": "2024-10-15", "Accept": "application/vnd.api+json", "Content-Type": "application/vnd.api+json" }, + "bodyMapping": { "data": "$data" } + } + }, + { + "name": "klaviyo_list_lists", + "description": "List all opted-in lists (newsletter subscriber lists). Each list has name, created, updated, opt_in_process (single_opt_in or double_opt_in).", + "parameters": { + "type": "object", + "properties": { + "filter": { "type": "string", "description": "Filter DSL." }, + "page_cursor": { "type": "string", "description": "Pagination cursor." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/lists", + "headers": { "revision": "2024-10-15", "Accept": "application/vnd.api+json" }, + "queryParams": { "filter": "$filter", "page[cursor]": "$page_cursor" } + } + }, + { + "name": "klaviyo_add_profiles_to_list", + "description": "Subscribe profiles to a list (silent — does NOT send the double-opt-in email even on lists that require it; for double-opt-in respecting, use klaviyo_subscribe_profiles instead).", + "parameters": { + "type": "object", + "properties": { + "listId": { "type": "string", "description": "List ID." }, + "data": { + "type": "array", + "description": "Array of profile references: [{type:'profile', id:'01...'}, ...]. Each profile must already exist (use klaviyo_create_or_update_profile first)." + } + }, + "required": ["listId", "data"] + }, + "endpointMapping": { + "method": "POST", + "path": "/lists/{listId}/relationships/profiles", + "headers": { "revision": "2024-10-15", "Accept": "application/vnd.api+json", "Content-Type": "application/vnd.api+json" }, + "bodyMapping": { "data": "$data" } + } + }, + { + "name": "klaviyo_subscribe_profiles", + "description": "Subscribe profiles to a list with CONSENT (triggers the double-opt-in email if the list requires it). Use this instead of klaviyo_add_profiles_to_list for compliance-sensitive onboarding.", + "parameters": { + "type": "object", + "properties": { + "data": { + "type": "object", + "description": "{type:'profile-subscription-bulk-create-job', attributes:{custom_source?:'string',profiles:{data:[{type:'profile',attributes:{email,subscriptions:{email:{marketing:{consent:'SUBSCRIBED'}}}}}]},relationships:{list:{data:{type:'list',id:'LIST_ID'}}}}}. Wrap in {data:{...}}." + } + }, + "required": ["data"] + }, + "endpointMapping": { + "method": "POST", + "path": "/profile-subscription-bulk-create-jobs", + "headers": { "revision": "2024-10-15", "Accept": "application/vnd.api+json", "Content-Type": "application/vnd.api+json" }, + "bodyMapping": { "data": "$data" } + } + }, + { + "name": "klaviyo_remove_profiles_from_list", + "description": "Unsubscribe profiles from a list. Removes them from the list — does NOT mark them as globally unsubscribed.", + "parameters": { + "type": "object", + "properties": { + "listId": { "type": "string", "description": "List ID." }, + "data": { + "type": "array", + "description": "Array of profile references: [{type:'profile', id:'01...'}]." + } + }, + "required": ["listId", "data"] + }, + "endpointMapping": { + "method": "DELETE", + "path": "/lists/{listId}/relationships/profiles", + "headers": { "revision": "2024-10-15", "Accept": "application/vnd.api+json", "Content-Type": "application/vnd.api+json" }, + "bodyMapping": { "data": "$data" } + } + }, + { + "name": "klaviyo_list_segments", + "description": "List dynamic segments. Each segment has name, definition, profile_count (estimated), is_active.", + "parameters": { + "type": "object", + "properties": { + "filter": { "type": "string", "description": "Filter DSL." }, + "page_cursor": { "type": "string", "description": "Pagination cursor." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/segments", + "headers": { "revision": "2024-10-15", "Accept": "application/vnd.api+json" }, + "queryParams": { "filter": "$filter", "page[cursor]": "$page_cursor" } + } + }, + { + "name": "klaviyo_create_event", + "description": "Track an event on a profile — the cornerstone of Klaviyo's behavioral targeting. Use for purchases, page views, cart adds, support tickets, anything triggering a flow.", + "parameters": { + "type": "object", + "properties": { + "data": { + "type": "object", + "description": "{type:'event', attributes:{properties:{...}, metric:{data:{type:'metric',attributes:{name:'Placed Order',service:'api'}}}, profile:{data:{type:'profile',attributes:{email:'a@b.com'}}}, value?:99.99, value_currency?:'USD', unique_id?:'uuid', time?:'2025-01-15T12:00:00Z'}}. Wrap in {data:{...}}." + } + }, + "required": ["data"] + }, + "endpointMapping": { + "method": "POST", + "path": "/events", + "headers": { "revision": "2024-10-15", "Accept": "application/vnd.api+json", "Content-Type": "application/vnd.api+json" }, + "bodyMapping": { "data": "$data" } + } + }, + { + "name": "klaviyo_list_events", + "description": "List events with filters. Each event has metric_id, profile_id, properties, datetime, uuid.", + "parameters": { + "type": "object", + "properties": { + "filter": { "type": "string", "description": "Filter DSL: equals(metric_id,'METRIC_ID'), greater-than(datetime,2024-01-01T00:00:00Z), etc." }, + "include": { "type": "string", "description": "Side-load: profile, metric." }, + "sort": { "type": "string", "description": "datetime, -datetime, timestamp, -timestamp." }, + "page_cursor": { "type": "string", "description": "Pagination cursor." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/events", + "headers": { "revision": "2024-10-15", "Accept": "application/vnd.api+json" }, + "queryParams": { + "filter": "$filter", + "include": "$include", + "sort": "$sort", + "page[cursor]": "$page_cursor" + } + } + }, + { + "name": "klaviyo_list_metrics", + "description": "List metrics (event types) the account has seen. Each metric has name, integration_id, created, updated. Use to discover metric IDs for event filtering.", + "parameters": { + "type": "object", + "properties": { + "filter": { "type": "string", "description": "Filter DSL on name or integration name." }, + "page_cursor": { "type": "string", "description": "Pagination cursor." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/metrics", + "headers": { "revision": "2024-10-15", "Accept": "application/vnd.api+json" }, + "queryParams": { "filter": "$filter", "page[cursor]": "$page_cursor" } + } + }, + { + "name": "klaviyo_list_campaigns", + "description": "List email/SMS campaigns. Filter by channel (email|sms) and status (Sent/Draft/Scheduled/etc).", + "parameters": { + "type": "object", + "properties": { + "filter": { "type": "string", "description": "REQUIRED: must include equals(messages.channel,'email') or 'sms'. E.g. 'and(equals(messages.channel,\\'email\\'),equals(status,\\'Sent\\'))'." }, + "include": { "type": "string", "description": "Side-load: campaign-messages, tags." }, + "sort": { "type": "string", "description": "created_at, scheduled_at." }, + "page_cursor": { "type": "string", "description": "Pagination cursor." } + }, + "required": ["filter"] + }, + "endpointMapping": { + "method": "GET", + "path": "/campaigns", + "headers": { "revision": "2024-10-15", "Accept": "application/vnd.api+json" }, + "queryParams": { + "filter": "$filter", + "include": "$include", + "sort": "$sort", + "page[cursor]": "$page_cursor" + } + } + }, + { + "name": "klaviyo_list_flows", + "description": "List flows (triggered automation sequences). Each flow has name, status, trigger_type, created, updated. Filter by status (active/draft/manual).", + "parameters": { + "type": "object", + "properties": { + "filter": { "type": "string", "description": "Filter DSL." }, + "include": { "type": "string", "description": "Side-load: flow-actions, tags." }, + "sort": { "type": "string", "description": "id, name, status, created, updated, trigger_type." }, + "page_cursor": { "type": "string", "description": "Pagination cursor." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/flows", + "headers": { "revision": "2024-10-15", "Accept": "application/vnd.api+json" }, + "queryParams": { + "filter": "$filter", + "include": "$include", + "sort": "$sort", + "page[cursor]": "$page_cursor" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/klaviyo.live.spec.ts b/packages/backend/src/adapters/intl/klaviyo.live.spec.ts new file mode 100644 index 0000000..8c63ce2 --- /dev/null +++ b/packages/backend/src/adapters/intl/klaviyo.live.spec.ts @@ -0,0 +1,54 @@ +import * as adapter from './klaviyo.json'; +import { RestEngine } from '../../connectors/engines/rest.engine'; +import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service'; +import { LoginTokenService } from '../../connectors/engines/login-token.service'; + +/** Live: RUN_KLAVIYO_LIVE=1 npx jest src/adapters/intl/klaviyo.live.spec.ts */ + +interface Tool { name: string; endpointMapping: { method: string; path: string; headers?: Record } } +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: Record }; + tools: Tool[]; +}; + +describe('klaviyo adapter — static spec conformance', () => { + it('uses a.klaviyo.com/api', () => { + expect(a.connector.baseUrl).toBe('https://a.klaviyo.com/api'); + }); + it('uses Klaviyo-API-Key prefix (NOT Bearer)', () => { + expect(a.connector.authType).toBe('API_KEY'); + expect(a.connector.authConfig.headerName).toBe('Authorization'); + expect(a.connector.authConfig.apiKey).toBe('Klaviyo-API-Key {{KLAVIYO_PRIVATE_API_KEY}}'); + }); + it('every tool pins the revision header to 2024-10-15', () => { + for (const t of a.tools) { + expect(t.endpointMapping.headers?.revision).toBe('2024-10-15'); + } + }); +}); + +const maybe = process.env.RUN_KLAVIYO_LIVE ? describe : describe.skip; +maybe('klaviyo adapter — live edge reachability', () => { + const engine = new RestEngine({} as OAuth2TokenService, {} as LoginTokenService); + + it('GET /accounts reaches Klaviyo edge (401)', async () => { + let err: any; + try { + await engine.execute( + { + baseUrl: a.connector.baseUrl, + authType: 'API_KEY', + authConfig: { headerName: 'Authorization', apiKey: 'Klaviyo-API-Key pk_bogus' }, + }, + { + method: 'GET', + path: '/accounts', + headers: { revision: '2024-10-15', Accept: 'application/vnd.api+json' }, + }, + {}, + ); + } catch (e) { err = e; } + expect(err).toBeDefined(); + expect([401, 403]).toContain(err.response?.status); + }, 30000); +}); diff --git a/packages/backend/src/adapters/intl/lemonsqueezy.json b/packages/backend/src/adapters/intl/lemonsqueezy.json new file mode 100644 index 0000000..4db2f14 --- /dev/null +++ b/packages/backend/src/adapters/intl/lemonsqueezy.json @@ -0,0 +1,323 @@ +{ + "slug": "lemonsqueezy", + "name": "Lemon Squeezy", + "description": "Read and act on Lemon Squeezy commerce data — orders, subscriptions, customers, products, variants, license keys, discounts, payouts — from any AI agent. 14 tools, Bearer auth. JSON:API spec compliant.", + "instructions": "This connector uses the Lemon Squeezy API v1.\n\n**Setup**:\n1. Sign in to https://app.lemonsqueezy.com → **Settings → API → + Create API key**.\n2. Name it ('AnythingMCP'), select the store this key acts on (or 'All stores' if you have multiple).\n3. Copy the key. It's prefixed with `eyJ...` (it's a JWT, but treat as opaque). Set `LEMONSQUEEZY_API_KEY`.\n\n**Authentication**: `Authorization: Bearer ${LEMONSQUEEZY_API_KEY}` + `Accept: application/vnd.api+json` (the adapter sets the Accept header automatically per endpoint).\n\n**JSON:API conventions**: every response wraps data in `{data: ..., meta: {page: {...}}, links: {...}}`. Each resource has `id`, `type`, `attributes`, `relationships`. **Use `?include=` to side-load related resources** in one call — e.g. `?include=order-items,customer` on an order. Without `include`, the relationships only return IDs and require N+1 lookups.\n\n**Filtering**: `?filter[store_id]=NNN`, `?filter[status]=paid`, etc. The adapter exposes the most common filters per endpoint.\n\n**Pagination**: `?page[number]=N` and `?page[size]=M` (max 100). `meta.page.lastPage` tells you the total. \n\n**Mode (test vs live)**: every object has a `test_mode` boolean attribute. The same API key can act on either; mode is determined by the resource you reference (test resources are created when your store is in test mode).\n\n**Store IDs**: most resources scope to a store. Find your store_id via `lemonsqueezy_list_stores` once. If you only have one store, every list call defaults to it.\n\n**License keys** are a Lemon Squeezy first-class product type — useful for SaaS apps. The adapter exposes list + retrieve. Activation/deactivation lives at the public license-keys endpoint (different auth) and is intentionally out of scope here.\n\n**Webhooks** out of scope (configured in the UI and delivered to your hosted endpoint).\n\n**Rate limits**: 300 req/min per API key. On 429, back off. `Retry-After` header is set.\n\n**Out of scope here**: writes to products/variants/files (Lemon Squeezy is a curated marketplace and asks you to manage catalog via UI), checkout link creation (use the simpler hosted-checkout URL), affiliates, store theme.", + "region": "intl", + "category": "payments", + "icon": "lemonsqueezy", + "docsUrl": "https://docs.lemonsqueezy.com/api", + "requiredEnvVars": ["LEMONSQUEEZY_API_KEY"], + "connector": { + "name": "Lemon Squeezy v1", + "type": "REST", + "baseUrl": "https://api.lemonsqueezy.com/v1", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{LEMONSQUEEZY_API_KEY}}" + } + }, + "tools": [ + { + "name": "lemonsqueezy_get_current_user", + "description": "Return the user the API key belongs to (whoami). Also confirms the key is valid.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { + "method": "GET", + "path": "/users/me", + "headers": { "Accept": "application/vnd.api+json" } + } + }, + { + "name": "lemonsqueezy_list_stores", + "description": "List stores the API key has access to. Returns each store's id, name, slug, domain, url, currency, plan, payment_processors[], default_currency, total_sales, total_revenue.", + "parameters": { + "type": "object", + "properties": { + "page_number": { "type": "integer", "description": "Page number, default 1." }, + "page_size": { "type": "integer", "description": "Per page, max 100." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/stores", + "headers": { "Accept": "application/vnd.api+json" }, + "queryParams": { "page[number]": "$page_number", "page[size]": "$page_size" } + } + }, + { + "name": "lemonsqueezy_list_products", + "description": "List products for a store. Each product has name, slug, description, price (cents), thumb_url, status (published/draft), variants_count.", + "parameters": { + "type": "object", + "properties": { + "store_id": { "type": "integer", "description": "Filter by store ID." }, + "include": { "type": "string", "description": "Side-load related resources: 'variants', 'store'." }, + "page_number": { "type": "integer", "description": "Page number." }, + "page_size": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/products", + "headers": { "Accept": "application/vnd.api+json" }, + "queryParams": { + "filter[store_id]": "$store_id", + "include": "$include", + "page[number]": "$page_number", + "page[size]": "$page_size" + } + } + }, + { + "name": "lemonsqueezy_list_variants", + "description": "List variants — the SKUs of a product (e.g. monthly vs annual plan, or different licensing tiers). Each variant has price, interval, interval_count, is_subscription, has_license_keys.", + "parameters": { + "type": "object", + "properties": { + "product_id": { "type": "integer", "description": "Filter by product ID." }, + "include": { "type": "string", "description": "Side-load: 'product', 'price-model'." }, + "page_number": { "type": "integer", "description": "Page number." }, + "page_size": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/variants", + "headers": { "Accept": "application/vnd.api+json" }, + "queryParams": { + "filter[product_id]": "$product_id", + "include": "$include", + "page[number]": "$page_number", + "page[size]": "$page_size" + } + } + }, + { + "name": "lemonsqueezy_list_customers", + "description": "List customers for a store. Each customer has email, name, status, country, total_spent, total_revenue_currency.", + "parameters": { + "type": "object", + "properties": { + "store_id": { "type": "integer", "description": "Filter by store." }, + "email": { "type": "string", "description": "Exact-match filter by email." }, + "page_number": { "type": "integer", "description": "Page number." }, + "page_size": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/customers", + "headers": { "Accept": "application/vnd.api+json" }, + "queryParams": { + "filter[store_id]": "$store_id", + "filter[email]": "$email", + "page[number]": "$page_number", + "page[size]": "$page_size" + } + } + }, + { + "name": "lemonsqueezy_get_customer", + "description": "Fetch one customer with their full purchase history (use include=orders,subscriptions).", + "parameters": { + "type": "object", + "properties": { + "customerId": { "type": "integer", "description": "Customer ID." }, + "include": { "type": "string", "description": "Side-load: 'orders', 'subscriptions', 'license-keys'." } + }, + "required": ["customerId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/customers/{customerId}", + "headers": { "Accept": "application/vnd.api+json" }, + "queryParams": { "include": "$include" } + } + }, + { + "name": "lemonsqueezy_list_orders", + "description": "List orders for a store with optional filters. Each order has identifier, order_number, user_email, currency, subtotal, tax, total (cents), status (paid/refunded/pending/failed), refunded, refunded_at, created_at.", + "parameters": { + "type": "object", + "properties": { + "store_id": { "type": "integer", "description": "Store filter." }, + "user_email": { "type": "string", "description": "Filter by customer email." }, + "status": { "type": "string", "description": "paid, refunded, pending, failed." }, + "include": { "type": "string", "description": "Side-load: 'order-items', 'customer', 'subscription'." }, + "page_number": { "type": "integer", "description": "Page number." }, + "page_size": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/orders", + "headers": { "Accept": "application/vnd.api+json" }, + "queryParams": { + "filter[store_id]": "$store_id", + "filter[user_email]": "$user_email", + "filter[status]": "$status", + "include": "$include", + "page[number]": "$page_number", + "page[size]": "$page_size" + } + } + }, + { + "name": "lemonsqueezy_get_order", + "description": "Fetch one order with full details. Include order-items to see line items.", + "parameters": { + "type": "object", + "properties": { + "orderId": { "type": "integer", "description": "Order ID." }, + "include": { "type": "string", "description": "Side-load order-items, store, customer." } + }, + "required": ["orderId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/orders/{orderId}", + "headers": { "Accept": "application/vnd.api+json" }, + "queryParams": { "include": "$include" } + } + }, + { + "name": "lemonsqueezy_list_subscriptions", + "description": "List subscriptions with status filters. Each subscription has status (on_trial/active/past_due/unpaid/cancelled/expired), renews_at, ends_at, trial_ends_at, billing_anchor, urls (update_payment_method, customer_portal).", + "parameters": { + "type": "object", + "properties": { + "store_id": { "type": "integer", "description": "Store filter." }, + "status": { "type": "string", "description": "on_trial, active, past_due, unpaid, cancelled, expired." }, + "user_email": { "type": "string", "description": "Filter by customer email." }, + "product_id": { "type": "integer", "description": "Filter by product." }, + "include": { "type": "string", "description": "Side-load: 'customer', 'order', 'variant', 'subscription-invoices'." }, + "page_number": { "type": "integer", "description": "Page number." }, + "page_size": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/subscriptions", + "headers": { "Accept": "application/vnd.api+json" }, + "queryParams": { + "filter[store_id]": "$store_id", + "filter[status]": "$status", + "filter[user_email]": "$user_email", + "filter[product_id]": "$product_id", + "include": "$include", + "page[number]": "$page_number", + "page[size]": "$page_size" + } + } + }, + { + "name": "lemonsqueezy_get_subscription", + "description": "Fetch one subscription with detailed billing state.", + "parameters": { + "type": "object", + "properties": { + "subscriptionId": { "type": "integer", "description": "Subscription ID." }, + "include": { "type": "string", "description": "Side-load order, customer, variant, product, subscription-invoices." } + }, + "required": ["subscriptionId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/subscriptions/{subscriptionId}", + "headers": { "Accept": "application/vnd.api+json" }, + "queryParams": { "include": "$include" } + } + }, + { + "name": "lemonsqueezy_cancel_subscription", + "description": "Cancel a subscription. By default cancels at period end (renews_at). The customer keeps access until renews_at. Use this for self-service cancellation flows; for immediate termination + refund, refund via lemonsqueezy_get_order's refund link instead.", + "parameters": { + "type": "object", + "properties": { + "subscriptionId": { "type": "integer", "description": "Subscription ID." } + }, + "required": ["subscriptionId"] + }, + "endpointMapping": { + "method": "DELETE", + "path": "/subscriptions/{subscriptionId}", + "headers": { "Accept": "application/vnd.api+json" } + } + }, + { + "name": "lemonsqueezy_list_license_keys", + "description": "List license keys (relevant for products with has_license_keys=true). Each key has key (the actual license string), status (inactive/active/expired/disabled), activation_limit, instances_count, expires_at, customer_id.", + "parameters": { + "type": "object", + "properties": { + "store_id": { "type": "integer", "description": "Store filter." }, + "product_id": { "type": "integer", "description": "Product filter." }, + "order_id": { "type": "integer", "description": "Filter by issuing order." }, + "order_item_id": { "type": "integer", "description": "Filter by issuing order-item." }, + "include": { "type": "string", "description": "Side-load: 'customer', 'product', 'order', 'license-key-instances'." }, + "page_number": { "type": "integer", "description": "Page number." }, + "page_size": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/license-keys", + "headers": { "Accept": "application/vnd.api+json" }, + "queryParams": { + "filter[store_id]": "$store_id", + "filter[product_id]": "$product_id", + "filter[order_id]": "$order_id", + "filter[order_item_id]": "$order_item_id", + "include": "$include", + "page[number]": "$page_number", + "page[size]": "$page_size" + } + } + }, + { + "name": "lemonsqueezy_list_discounts", + "description": "List discount codes for a store. Each discount has name, code, amount, amount_type (percent/fixed), starts_at, expires_at, status, uses, max_uses.", + "parameters": { + "type": "object", + "properties": { + "store_id": { "type": "integer", "description": "Store filter." }, + "page_number": { "type": "integer", "description": "Page number." }, + "page_size": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/discounts", + "headers": { "Accept": "application/vnd.api+json" }, + "queryParams": { + "filter[store_id]": "$store_id", + "page[number]": "$page_number", + "page[size]": "$page_size" + } + } + }, + { + "name": "lemonsqueezy_list_payouts", + "description": "List payouts to your bank account (Lemon Squeezy as Merchant of Record handles tax + remits net to you). Each payout has amount, status, date_paid, currency.", + "parameters": { + "type": "object", + "properties": { + "store_id": { "type": "integer", "description": "Store filter." }, + "page_number": { "type": "integer", "description": "Page number." }, + "page_size": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/payouts", + "headers": { "Accept": "application/vnd.api+json" }, + "queryParams": { + "filter[store_id]": "$store_id", + "page[number]": "$page_number", + "page[size]": "$page_size" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/lemonsqueezy.live.spec.ts b/packages/backend/src/adapters/intl/lemonsqueezy.live.spec.ts new file mode 100644 index 0000000..bd8c7cd --- /dev/null +++ b/packages/backend/src/adapters/intl/lemonsqueezy.live.spec.ts @@ -0,0 +1,45 @@ +import * as adapter from './lemonsqueezy.json'; +import { RestEngine } from '../../connectors/engines/rest.engine'; +import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service'; +import { LoginTokenService } from '../../connectors/engines/login-token.service'; + +/** Live: RUN_LEMONSQUEEZY_LIVE=1 npx jest src/adapters/intl/lemonsqueezy.live.spec.ts */ + +interface Tool { name: string; endpointMapping: { method: string; path: string; headers?: Record } } +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: Record }; + tools: Tool[]; +}; + +describe('lemonsqueezy adapter — static spec conformance', () => { + it('uses api.lemonsqueezy.com/v1', () => { + expect(a.connector.baseUrl).toBe('https://api.lemonsqueezy.com/v1'); + }); + it('Bearer auth', () => { + expect(a.connector.authType).toBe('BEARER_TOKEN'); + expect(a.connector.authConfig.token).toBe('{{LEMONSQUEEZY_API_KEY}}'); + }); + it('every tool sets Accept: application/vnd.api+json (JSON:API requirement)', () => { + for (const t of a.tools) { + expect(t.endpointMapping.headers?.Accept).toBe('application/vnd.api+json'); + } + }); +}); + +const maybe = process.env.RUN_LEMONSQUEEZY_LIVE ? describe : describe.skip; +maybe('lemonsqueezy adapter — live edge reachability', () => { + const engine = new RestEngine({} as OAuth2TokenService, {} as LoginTokenService); + + it('GET /users/me reaches Lemon Squeezy edge (401)', async () => { + let err: any; + try { + await engine.execute( + { baseUrl: a.connector.baseUrl, authType: 'BEARER_TOKEN', authConfig: { token: 'bogus' } }, + { method: 'GET', path: '/users/me', headers: { Accept: 'application/vnd.api+json' } }, + {}, + ); + } catch (e) { err = e; } + expect(err).toBeDefined(); + expect([401, 403]).toContain(err.response?.status); + }, 30000); +}); diff --git a/packages/backend/src/adapters/intl/typeform.json b/packages/backend/src/adapters/intl/typeform.json new file mode 100644 index 0000000..b3018fa --- /dev/null +++ b/packages/backend/src/adapters/intl/typeform.json @@ -0,0 +1,187 @@ +{ + "slug": "typeform", + "name": "Typeform", + "description": "Read Typeform responses, list forms, fetch form definitions and manage workspaces from any AI agent. 10 tools, Personal Token Bearer auth. Optimized for read+analysis flows (the heavy form-builder write side is out of scope).", + "instructions": "This connector uses the Typeform Create/Responses API.\n\n**Setup**:\n1. Sign in to Typeform → top-right avatar → **Settings → Personal tokens → Generate a new token**.\n2. Grant the scopes you need — at minimum `forms:read`, `responses:read`, `workspaces:read`. For write operations add `forms:write`. The connector's `instructions` documents what scopes each tool requires implicitly.\n3. Copy the token (shown ONCE). Set `TYPEFORM_PERSONAL_TOKEN`.\n\n**Authentication**: Bearer token. Same token for all endpoints.\n\n**Form IDs**: each form has an 8-character alphanumeric ID (e.g. `Wf2hrM`), visible at the end of the form's public URL (`yourname.typeform.com/to/Wf2hrM` → ID is `Wf2hrM`). Use `typeform_list_forms` once to discover IDs you own.\n\n**Responses model**: Typeform stores each submission as a 'response' with `landed_at`, `submitted_at`, `answers[]`. Each answer is keyed by the field's `id` (UUID) AND by a `type` (text/email/multiple_choice/number/...) — so reading answers requires knowing the form's fields. Cross-reference with `typeform_get_form` to map field IDs to user-readable titles.\n\n**Pagination for responses**: `typeform_list_responses` accepts `since`/`until` timestamps or `before`/`after` token cursors. Default page_size 25, max 1000. For large exports, prefer date-range pagination over token cursors.\n\n**Incremental sync pattern**: store the highest `submitted_at` you've processed and use it as `since` on the next call. Typeform sorts responses newest-first by default — set `sort=submitted_at,asc` to walk forward.\n\n**Webhooks** out of scope (Typeform pushes on submission to a URL you host).\n\n**Hidden fields**: the `hidden` object on a response captures URL parameters passed to the form (e.g. `?utm_source=newsletter`). Read them via `responses[].hidden`.\n\n**Forms with logic / outcomes**: scoring branches, calculator outputs, and ranking results appear in `responses[].calculated` and `responses[].metadata`. Score-based quizzes typically use `calculated.score`.\n\n**Rate limits**: 2 requests/sec per token (Typeform default), 10000/day. On 429, back off — `Retry-After` header tells you how long.\n\n**Out of scope here**: creating/editing form structure (use the Typeform UI; the API for `forms:write` exists but the JSON tree is dense and rarely worth automating), images/themes upload, custom workspaces beyond list.", + "region": "intl", + "category": "forms", + "icon": "typeform", + "docsUrl": "https://www.typeform.com/developers/", + "requiredEnvVars": ["TYPEFORM_PERSONAL_TOKEN"], + "connector": { + "name": "Typeform API", + "type": "REST", + "baseUrl": "https://api.typeform.com", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{TYPEFORM_PERSONAL_TOKEN}}" + } + }, + "tools": [ + { + "name": "typeform_get_current_user", + "description": "Return the user the personal token belongs to: email, alias, language. Health check + whoami.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/me" } + }, + { + "name": "typeform_list_forms", + "description": "List forms (paginated). Returns each form's id, title, type, settings.is_public, _links.display URL, last_updated_at, workspace_id.", + "parameters": { + "type": "object", + "properties": { + "page": { "type": "integer", "description": "Page number (1-based, default 1)." }, + "page_size": { "type": "integer", "description": "Forms per page (default 10, max 200)." }, + "search": { "type": "string", "description": "Substring filter on form titles." }, + "workspace_id": { "type": "string", "description": "Only forms in this workspace." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/forms", + "queryParams": { + "page": "$page", + "page_size": "$page_size", + "search": "$search", + "workspace_id": "$workspace_id" + } + } + }, + { + "name": "typeform_get_form", + "description": "Fetch a form's full definition: title, fields[] (each with id, title, type, properties, ref, validations), thankyou_screens, welcome_screens, logic, settings. Required to map answer field IDs to human-readable titles.", + "parameters": { + "type": "object", + "properties": { + "formId": { "type": "string", "description": "8-char form ID." } + }, + "required": ["formId"] + }, + "endpointMapping": { "method": "GET", "path": "/forms/{formId}" } + }, + { + "name": "typeform_list_responses", + "description": "List responses (submissions) for a form, paginated. Each response: response_id, landed_at, submitted_at, hidden{utm_*}, answers[{field:{id,type,ref}, type, ...typed value...}], metadata{platform,browser,network_id}, calculated{score?}.", + "parameters": { + "type": "object", + "properties": { + "formId": { "type": "string", "description": "Form ID." }, + "page_size": { "type": "integer", "description": "Responses per page (default 25, max 1000)." }, + "since": { "type": "string", "description": "ISO 8601 — only responses with submitted_at >= this. Best for incremental sync." }, + "until": { "type": "string", "description": "ISO 8601 — submitted_at < this." }, + "after": { "type": "string", "description": "Token cursor — return responses after this token (from prior page)." }, + "before": { "type": "string", "description": "Token cursor — return responses before this token." }, + "included_response_ids": { "type": "string", "description": "Comma-separated response_id list to bypass pagination and fetch specific ones." }, + "completed": { "type": "boolean", "description": "true=only completed, false=only partial, omit=all." }, + "sort": { "type": "string", "description": "Sort by submitted_at: 'submitted_at,asc' (oldest first) or 'submitted_at,desc' (default)." }, + "query": { "type": "string", "description": "Full-text search across answers." }, + "fields": { "type": "string", "description": "Comma-separated field IDs to return only answers for these (reduces response size)." } + }, + "required": ["formId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/forms/{formId}/responses", + "queryParams": { + "page_size": "$page_size", + "since": "$since", + "until": "$until", + "after": "$after", + "before": "$before", + "included_response_ids": "$included_response_ids", + "completed": "$completed", + "sort": "$sort", + "query": "$query", + "fields": "$fields" + } + } + }, + { + "name": "typeform_delete_responses", + "description": "Delete responses by their IDs (max 1000 per call). Irreversible. Use for GDPR right-to-be-forgotten requests; for normal cleanup prefer keeping data.", + "parameters": { + "type": "object", + "properties": { + "formId": { "type": "string", "description": "Form ID." }, + "included_response_ids": { "type": "string", "description": "Comma-separated response IDs to delete." } + }, + "required": ["formId", "included_response_ids"] + }, + "endpointMapping": { + "method": "DELETE", + "path": "/forms/{formId}/responses", + "queryParams": { + "included_response_ids": "$included_response_ids" + } + } + }, + { + "name": "typeform_list_workspaces", + "description": "List workspaces (folders for organizing forms). Each workspace has id, name, default flag, members count, forms count, _links.self URL.", + "parameters": { + "type": "object", + "properties": { + "page": { "type": "integer", "description": "Page number." }, + "page_size": { "type": "integer", "description": "Per page (default 10)." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/workspaces", + "queryParams": { "page": "$page", "page_size": "$page_size" } + } + }, + { + "name": "typeform_get_workspace", + "description": "Fetch one workspace's full details including members[] and forms[] (URI list).", + "parameters": { + "type": "object", + "properties": { + "workspaceId": { "type": "string", "description": "Workspace ID." } + }, + "required": ["workspaceId"] + }, + "endpointMapping": { "method": "GET", "path": "/workspaces/{workspaceId}" } + }, + { + "name": "typeform_list_form_messages", + "description": "Get the localized messages (button text, validation errors, etc.) configured on a form. Useful when building a custom embed.", + "parameters": { + "type": "object", + "properties": { + "formId": { "type": "string", "description": "Form ID." } + }, + "required": ["formId"] + }, + "endpointMapping": { "method": "GET", "path": "/forms/{formId}/messages" } + }, + { + "name": "typeform_get_form_insights", + "description": "Fetch aggregate insights (visits, submissions, completion_rate, average_time_to_complete, etc.) for a form. Returns one summary block.", + "parameters": { + "type": "object", + "properties": { + "formId": { "type": "string", "description": "Form ID." } + }, + "required": ["formId"] + }, + "endpointMapping": { "method": "GET", "path": "/insights/{formId}/summary" } + }, + { + "name": "typeform_list_themes", + "description": "List visual themes (color/font/background combos) available on the account. Use to apply a theme when programmatically creating forms.", + "parameters": { + "type": "object", + "properties": { + "page": { "type": "integer", "description": "Page number." }, + "page_size": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/themes", + "queryParams": { "page": "$page", "page_size": "$page_size" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/typeform.live.spec.ts b/packages/backend/src/adapters/intl/typeform.live.spec.ts new file mode 100644 index 0000000..a241c24 --- /dev/null +++ b/packages/backend/src/adapters/intl/typeform.live.spec.ts @@ -0,0 +1,44 @@ +import * as adapter from './typeform.json'; +import { RestEngine } from '../../connectors/engines/rest.engine'; +import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service'; +import { LoginTokenService } from '../../connectors/engines/login-token.service'; + +/** Live: RUN_TYPEFORM_LIVE=1 npx jest src/adapters/intl/typeform.live.spec.ts */ + +interface Tool { name: string; endpointMapping: { method: string; path: string } } +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: Record }; + tools: Tool[]; +}; + +describe('typeform adapter — static spec conformance', () => { + it('uses api.typeform.com', () => { + expect(a.connector.baseUrl).toBe('https://api.typeform.com'); + }); + it('Bearer auth', () => { + expect(a.connector.authType).toBe('BEARER_TOKEN'); + expect(a.connector.authConfig.token).toBe('{{TYPEFORM_PERSONAL_TOKEN}}'); + }); + it('list-responses correctly nested under /forms/{formId}/responses', () => { + const t = a.tools.find((x) => x.name === 'typeform_list_responses')!; + expect(t.endpointMapping.path).toBe('/forms/{formId}/responses'); + }); +}); + +const maybe = process.env.RUN_TYPEFORM_LIVE ? describe : describe.skip; +maybe('typeform adapter — live edge reachability', () => { + const engine = new RestEngine({} as OAuth2TokenService, {} as LoginTokenService); + + it('GET /me reaches Typeform edge (401)', async () => { + let err: any; + try { + await engine.execute( + { baseUrl: a.connector.baseUrl, authType: 'BEARER_TOKEN', authConfig: { token: 'bogus' } }, + { method: 'GET', path: '/me' }, + {}, + ); + } catch (e) { err = e; } + expect(err).toBeDefined(); + expect(err.response?.status).toBe(401); + }, 30000); +}); diff --git a/packages/backend/src/adapters/intl/zendesk.json b/packages/backend/src/adapters/intl/zendesk.json new file mode 100644 index 0000000..a146da8 --- /dev/null +++ b/packages/backend/src/adapters/intl/zendesk.json @@ -0,0 +1,329 @@ +{ + "slug": "zendesk", + "name": "Zendesk Support", + "description": "Drive Zendesk Support (the help-desk product) from any AI agent: tickets, users, organizations, groups, macros, views, search. 18 tools covering the most common agent workflows. Basic-auth with email+API token.", + "instructions": "This connector uses the Zendesk Support REST API v2.\n\n**Setup**:\n1. Sign in to Zendesk as an admin → **Admin Center → Apps and integrations → APIs → Zendesk API → Settings → Token access ON**.\n2. **Add API token**, label it (e.g. 'AnythingMCP'), copy the generated token. It's shown ONCE.\n3. Set:\n - `ZENDESK_SUBDOMAIN` = your account subdomain (e.g. `acme` if your URL is `acme.zendesk.com`)\n - `ZENDESK_EMAIL` = the agent email of an admin user (the requests will be attributed to this user)\n - `ZENDESK_API_TOKEN` = the token from step 2\n\n**Authentication**: HTTP Basic Auth with username `{email}/token` and password `{API_TOKEN}`. The adapter constructs this from the 3 env vars via the BASIC_AUTH profile. To use OAuth2 access tokens instead (for multi-user installs), the engine can also send `Authorization: Bearer ${oauth_token}` — but for single-tenant agent setups the API token model is simpler.\n\n**Base URL**: `https://{{ZENDESK_SUBDOMAIN}}.zendesk.com/api/v2`. The subdomain is part of the base URL — if you set the wrong one, every request 404s.\n\n**Tickets** are the central object. A ticket has subject, description, requester_id (the end user who reported), assignee_id (the agent), status (new/open/pending/hold/solved/closed), priority (low/normal/high/urgent), type (question/incident/problem/task), tags[], custom_fields.\n\n**Status transitions**: `closed` is terminal — once closed, a ticket can't be reopened (Zendesk forces a follow-up ticket). Use `solved` for 'done, awaiting closing-grace-period'. `pending` = awaiting requester response.\n\n**Update vs Comment**: to reply to a ticket from the agent side, you UPDATE the ticket with a `comment` object: `{public: true, body: '...'}`. public=false makes it an internal note (not visible to the requester). Use `zendesk_update_ticket` with `comment` for both.\n\n**Search**: Zendesk's Search API uses a Lucene-like query language. `type:ticket status:open assignee:none` returns unassigned open tickets. `type:user email:*@acme.com` returns all users at acme. Full syntax: https://support.zendesk.com/hc/en-us/articles/4408886879258. The adapter's `zendesk_search` returns up to 1000 results across all types.\n\n**Pagination**: cursor-based — list endpoints return `meta.has_more` + `meta.after_cursor`. Pass the cursor as `page[after]` on the next call. Page size 100 by default, max 1000 (or 100 for some endpoints).\n\n**Side-loading**: many list endpoints support `?include=users,organizations` to bundle related objects in one response — avoids N+1 lookups when displaying a ticket list with requester names.\n\n**Rate limits**: 700 req/min per agent on Suite Team, scales up with plan. On 429, honor the `Retry-After` header.\n\n**Out of scope here**: triggers, automations, dynamic content, Talk (voice), Chat, Sell (CRM), Sunshine (custom objects beyond standard).", + "region": "intl", + "category": "support", + "icon": "zendesk", + "docsUrl": "https://developer.zendesk.com/api-reference/ticketing/", + "requiredEnvVars": ["ZENDESK_SUBDOMAIN", "ZENDESK_EMAIL", "ZENDESK_API_TOKEN"], + "connector": { + "name": "Zendesk Support v2", + "type": "REST", + "baseUrl": "https://{{ZENDESK_SUBDOMAIN}}.zendesk.com/api/v2", + "authType": "BASIC_AUTH", + "authConfig": { + "username": "{{ZENDESK_EMAIL}}/token", + "password": "{{ZENDESK_API_TOKEN}}" + } + }, + "tools": [ + { + "name": "zendesk_get_current_user", + "description": "Return the agent user that the API credentials belong to (whoami). Tells you which agent the requests are attributed to.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/users/me" } + }, + { + "name": "zendesk_search", + "description": "Universal search using Zendesk's Lucene-like query syntax. Example queries: 'type:ticket status:open assignee:none', 'type:user email:*@acme.com', 'type:organization tags:vip'. Returns tickets, users, orgs, groups, topics in one paged response.", + "parameters": { + "type": "object", + "properties": { + "query": { "type": "string", "description": "Search query in Zendesk Search syntax." }, + "sort_by": { "type": "string", "description": "Sort field: updated_at, created_at, priority, status, ticket_type, etc." }, + "sort_order": { "type": "string", "description": "asc or desc." }, + "page": { "type": "integer", "description": "Page number (offset pagination on search)." }, + "per_page": { "type": "integer", "description": "Results per page, default 100, max 100." } + }, + "required": ["query"] + }, + "endpointMapping": { + "method": "GET", + "path": "/search.json", + "queryParams": { + "query": "$query", + "sort_by": "$sort_by", + "sort_order": "$sort_order", + "page": "$page", + "per_page": "$per_page" + } + } + }, + { + "name": "zendesk_list_tickets", + "description": "List tickets (cursor-paginated, newest first). For filtered lists prefer zendesk_search with a query like 'type:ticket ...'. Returns full ticket objects.", + "parameters": { + "type": "object", + "properties": { + "page_size": { "type": "integer", "description": "Max per page (default 100, max 100)." }, + "page_after": { "type": "string", "description": "Cursor from prior meta.after_cursor." }, + "sort": { "type": "string", "description": "Sort field, prefix with - for descending: -updated_at, created_at." }, + "include": { "type": "string", "description": "Side-load related: users, organizations, groups, comma-separated." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/tickets.json", + "queryParams": { + "page[size]": "$page_size", + "page[after]": "$page_after", + "sort": "$sort", + "include": "$include" + } + } + }, + { + "name": "zendesk_get_ticket", + "description": "Fetch a single ticket by ID with all fields including custom_fields, tags, requester_id, assignee_id, group_id, status, priority, satisfaction_rating.", + "parameters": { + "type": "object", + "properties": { + "ticketId": { "type": "integer", "description": "Numeric ticket ID." }, + "include": { "type": "string", "description": "Side-load: users, organizations, groups, last_audits." } + }, + "required": ["ticketId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/tickets/{ticketId}.json", + "queryParams": { "include": "$include" } + } + }, + { + "name": "zendesk_create_ticket", + "description": "Create a new ticket. At minimum: subject + comment.body. `requester` lets you create the end user inline if they don't exist yet (provide their email + name).", + "parameters": { + "type": "object", + "properties": { + "ticket": { + "type": "object", + "description": "Ticket envelope: {subject, comment: {body, html_body?, public?}, requester_id? OR requester: {name,email}, assignee_id?, group_id?, priority?, type?, status?, tags?, custom_fields? [{id,value}], external_id?}. Wrap fields inside this object — Zendesk's API requires the {ticket: {...}} envelope." + } + }, + "required": ["ticket"] + }, + "endpointMapping": { + "method": "POST", + "path": "/tickets.json", + "bodyMapping": { + "ticket": "$ticket" + } + } + }, + { + "name": "zendesk_update_ticket", + "description": "Update a ticket. To add a public reply, set ticket.comment={public:true, body:'...'}. For internal note, public:false. To close, set status:'solved' (then closed automatically after grace period). Required envelope: {ticket: {...}}.", + "parameters": { + "type": "object", + "properties": { + "ticketId": { "type": "integer", "description": "Numeric ticket ID." }, + "ticket": { + "type": "object", + "description": "{comment?: {body,public,html_body?}, assignee_id?, status?, priority?, tags?, additional_tags?, remove_tags?, custom_fields?[{id,value}], collaborator_ids?, follower_ids?}." + } + }, + "required": ["ticketId", "ticket"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/tickets/{ticketId}.json", + "bodyMapping": { + "ticket": "$ticket" + } + } + }, + { + "name": "zendesk_delete_ticket", + "description": "Hard-delete a ticket (subject to GDPR retention policy). Cannot be undone. Use the UI's bulk archive for soft-delete behavior.", + "parameters": { + "type": "object", + "properties": { + "ticketId": { "type": "integer", "description": "Ticket ID." } + }, + "required": ["ticketId"] + }, + "endpointMapping": { "method": "DELETE", "path": "/tickets/{ticketId}.json" } + }, + { + "name": "zendesk_list_ticket_comments", + "description": "List all comments (public replies and internal notes) on a ticket, ordered by creation time. Each comment has body, html_body, public flag, author_id, attachments[], created_at.", + "parameters": { + "type": "object", + "properties": { + "ticketId": { "type": "integer", "description": "Ticket ID." }, + "sort_order": { "type": "string", "description": "asc or desc." }, + "include_inline_images": { "type": "boolean", "description": "Inline base64 images." } + }, + "required": ["ticketId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/tickets/{ticketId}/comments.json", + "queryParams": { + "sort_order": "$sort_order", + "include_inline_images": "$include_inline_images" + } + } + }, + { + "name": "zendesk_list_users", + "description": "List users (end-users, agents, admins). Cursor-paginated. For role filtering use ?role[]=end-user or ?role[]=agent.", + "parameters": { + "type": "object", + "properties": { + "role": { "type": "string", "description": "Filter by role: end-user, agent, admin." }, + "permission_set": { "type": "string", "description": "Filter by custom permission set ID." }, + "page_size": { "type": "integer", "description": "Max per page (default 100, max 100)." }, + "page_after": { "type": "string", "description": "Pagination cursor." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/users.json", + "queryParams": { + "role": "$role", + "permission_set": "$permission_set", + "page[size]": "$page_size", + "page[after]": "$page_after" + } + } + }, + { + "name": "zendesk_get_user", + "description": "Fetch a single user by ID. Returns email, phone, role, organization_id, tags, user_fields, time_zone, locale, last_login_at.", + "parameters": { + "type": "object", + "properties": { + "userId": { "type": "integer", "description": "Numeric user ID." } + }, + "required": ["userId"] + }, + "endpointMapping": { "method": "GET", "path": "/users/{userId}.json" } + }, + { + "name": "zendesk_create_or_update_user", + "description": "Upsert a user by email (matches existing if email already in system). Use to onboard requesters before they file their first ticket.", + "parameters": { + "type": "object", + "properties": { + "user": { + "type": "object", + "description": "{name, email, role?: end-user|agent|admin, phone?, organization_id?, tags?, user_fields?, time_zone?, locale_id?, verified?: true}. Wrap inside {user:{...}}." + } + }, + "required": ["user"] + }, + "endpointMapping": { + "method": "POST", + "path": "/users/create_or_update.json", + "bodyMapping": { + "user": "$user" + } + } + }, + { + "name": "zendesk_list_organizations", + "description": "List organizations. Each org has name, domain_names[], group_id, shared_tickets/comments flags, organization_fields.", + "parameters": { + "type": "object", + "properties": { + "page_size": { "type": "integer", "description": "Max per page." }, + "page_after": { "type": "string", "description": "Pagination cursor." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/organizations.json", + "queryParams": { + "page[size]": "$page_size", + "page[after]": "$page_after" + } + } + }, + { + "name": "zendesk_create_organization", + "description": "Create a new organization. `domain_names` array lets Zendesk auto-link end-users by email domain.", + "parameters": { + "type": "object", + "properties": { + "organization": { + "type": "object", + "description": "{name, domain_names?, tags?, organization_fields?, notes?, details?, group_id?}. Wrap inside {organization:{...}}." + } + }, + "required": ["organization"] + }, + "endpointMapping": { + "method": "POST", + "path": "/organizations.json", + "bodyMapping": { + "organization": "$organization" + } + } + }, + { + "name": "zendesk_list_groups", + "description": "List agent groups (used for ticket assignment routing).", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/groups.json" } + }, + { + "name": "zendesk_list_views", + "description": "List views — Zendesk's saved filter+sort definitions for tickets. Each view has id, title, conditions, execution.", + "parameters": { + "type": "object", + "properties": { + "access": { "type": "string", "description": "personal or shared." }, + "active": { "type": "boolean", "description": "Only active views." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/views.json", + "queryParams": { "access": "$access", "active": "$active" } + } + }, + { + "name": "zendesk_execute_view", + "description": "Run a view and return the tickets it currently matches. Faster than re-filtering manually for known canonical lists like 'My unsolved tickets' or 'High-priority queue'.", + "parameters": { + "type": "object", + "properties": { + "viewId": { "type": "integer", "description": "View ID." }, + "page": { "type": "integer", "description": "Page number." }, + "per_page": { "type": "integer", "description": "Results per page." } + }, + "required": ["viewId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/views/{viewId}/execute.json", + "queryParams": { "page": "$page", "per_page": "$per_page" } + } + }, + { + "name": "zendesk_list_ticket_fields", + "description": "List all ticket fields including custom ones with their IDs, types and allowed values. Required to compose custom_fields arrays in zendesk_create_ticket / zendesk_update_ticket.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/ticket_fields.json" } + }, + { + "name": "zendesk_list_macros", + "description": "List macros (saved sets of ticket updates an agent can apply with one click — e.g. 'Refund template', 'Escalate to L2'). Returns id, title, actions[], restriction.", + "parameters": { + "type": "object", + "properties": { + "access": { "type": "string", "description": "personal or shared." }, + "active": { "type": "boolean", "description": "Only active." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/macros.json", + "queryParams": { "access": "$access", "active": "$active" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/zendesk.live.spec.ts b/packages/backend/src/adapters/intl/zendesk.live.spec.ts new file mode 100644 index 0000000..d44da21 --- /dev/null +++ b/packages/backend/src/adapters/intl/zendesk.live.spec.ts @@ -0,0 +1,63 @@ +import * as adapter from './zendesk.json'; +import { RestEngine } from '../../connectors/engines/rest.engine'; +import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service'; +import { LoginTokenService } from '../../connectors/engines/login-token.service'; + +/** Live: RUN_ZENDESK_LIVE=1 npx jest src/adapters/intl/zendesk.live.spec.ts */ + +interface Tool { name: string; endpointMapping: { method: string; path: string } } +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: Record }; + tools: Tool[]; +}; + +describe('zendesk adapter — static spec conformance', () => { + it('uses subdomain-templated base URL', () => { + expect(a.connector.baseUrl).toBe('https://{{ZENDESK_SUBDOMAIN}}.zendesk.com/api/v2'); + }); + + it('uses BASIC_AUTH with the email/token suffix convention', () => { + expect(a.connector.authType).toBe('BASIC_AUTH'); + expect(a.connector.authConfig.username).toBe('{{ZENDESK_EMAIL}}/token'); + expect(a.connector.authConfig.password).toBe('{{ZENDESK_API_TOKEN}}'); + }); + + it('create-ticket requires the {ticket: {...}} envelope', () => { + const t = a.tools.find((x) => x.name === 'zendesk_create_ticket')!; + expect(t.endpointMapping.method).toBe('POST'); + expect(t.endpointMapping.path).toBe('/tickets.json'); + }); + + it('update-ticket uses PUT', () => { + const t = a.tools.find((x) => x.name === 'zendesk_update_ticket')!; + expect(t.endpointMapping.method).toBe('PUT'); + }); +}); + +const maybe = process.env.RUN_ZENDESK_LIVE ? describe : describe.skip; +maybe('zendesk adapter — live edge reachability', () => { + const engine = new RestEngine({} as OAuth2TokenService, {} as LoginTokenService); + + it('GET /users/me on a bogus subdomain returns 404 NXDOMAIN-like', async () => { + // No real way to validate path on Zendesk without a real subdomain since + // each customer has their own. We just confirm DNS resolution / TLS handshake + // works against a known-bogus subdomain — should be 404 or DNS failure. + let err: any; + try { + await engine.execute( + { + baseUrl: 'https://anythingmcp-smoke-test-no-such-subdomain.zendesk.com/api/v2', + authType: 'BASIC_AUTH', + authConfig: { username: 'bogus@example.com/token', password: 'bogus' }, + }, + { method: 'GET', path: '/users/me.json' }, + {}, + ); + } catch (e) { + err = e; + } + expect(err).toBeDefined(); + // Zendesk returns either 404 or a redirect to a login page for missing subdomains + expect([401, 404, 301, 302]).toContain(err.response?.status || 404); + }, 30000); +}); From b578a048c5235a18b3f13eb68ccd00315ce9e434 Mon Sep 17 00:00:00 2001 From: Matteo Date: Wed, 20 May 2026 12:23:05 +0200 Subject: [PATCH 05/19] connectors: add Close, Brevo, Kit (ConvertKit), Loops, ActiveCampaign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 2 of the greenfield batch — CRM extension + email/MA platforms not yet covered by vendor-official MCP servers. - Close (CRM): 16 tools — leads/contacts/opportunities CRUD with custom-field discovery, activities (note creation only — typed call/email/sms activities should come from real channels), tasks, smart-view queries, statuses. BASIC_AUTH with key-as-username. - Brevo (formerly Sendinblue, EU email/SMS): 14 tools — transactional email + SMS, contacts CRUD with double-opt-in awareness, lists and folders, campaigns create+sendNow, aggregated stats. Lowercase 'api-key' header convention. - Kit / ConvertKit v4: 14 tools — subscribers, tags, sequence enrollment, forms, broadcasts. Modern v4 Bearer API (uses the new key format from Settings → Advanced). - Loops (modern product email): 9 tools — contacts upsert via /contacts/create and /contacts/update, send transactional email with dataVariables, fire behavioral events to trigger Loops automations, list mailing lists and custom fields. - ActiveCampaign v3: 16 tools — contacts (sync, update, delete), list memberships with status integers (1=Active/2=Unsub/0=Pending double-opt-in), tags, deals + pipelines + stages, custom fields. Custom Api-Token header, account-templated base URL. Catalog: 55 adapters total. All 5 smoke-tested (401 from vendor edge confirms path recognition). --- packages/backend/src/adapters/catalog.ts | 10 + .../src/adapters/intl/activecampaign.json | 308 +++++++++++++++ .../adapters/intl/activecampaign.live.spec.ts | 22 ++ packages/backend/src/adapters/intl/brevo.json | 355 ++++++++++++++++++ .../src/adapters/intl/brevo.live.spec.ts | 40 ++ packages/backend/src/adapters/intl/close.json | 353 +++++++++++++++++ .../src/adapters/intl/close.live.spec.ts | 42 +++ .../backend/src/adapters/intl/convertkit.json | 275 ++++++++++++++ .../src/adapters/intl/convertkit.live.spec.ts | 34 ++ packages/backend/src/adapters/intl/loops.json | 186 +++++++++ .../src/adapters/intl/loops.live.spec.ts | 34 ++ 11 files changed, 1659 insertions(+) create mode 100644 packages/backend/src/adapters/intl/activecampaign.json create mode 100644 packages/backend/src/adapters/intl/activecampaign.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/brevo.json create mode 100644 packages/backend/src/adapters/intl/brevo.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/close.json create mode 100644 packages/backend/src/adapters/intl/close.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/convertkit.json create mode 100644 packages/backend/src/adapters/intl/convertkit.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/loops.json create mode 100644 packages/backend/src/adapters/intl/loops.live.spec.ts diff --git a/packages/backend/src/adapters/catalog.ts b/packages/backend/src/adapters/catalog.ts index 5831704..87696b0 100644 --- a/packages/backend/src/adapters/catalog.ts +++ b/packages/backend/src/adapters/catalog.ts @@ -31,10 +31,15 @@ import * as weclapp from './de/weclapp.json'; import * as xentral from './de/xentral.json'; import * as companiesHouse from './gb/companies-house.json'; import * as wise from './gb/wise.json'; +import * as activecampaign from './intl/activecampaign.json'; +import * as brevo from './intl/brevo.json'; import * as calendly from './intl/calendly.json'; +import * as close from './intl/close.json'; +import * as convertkit from './intl/convertkit.json'; import * as discordBot from './intl/discord-bot.json'; import * as klaviyo from './intl/klaviyo.json'; import * as lemonsqueezy from './intl/lemonsqueezy.json'; +import * as loops from './intl/loops.json'; import * as mailchimp from './intl/mailchimp.json'; import * as pipedrive from './intl/pipedrive.json'; import * as sendgrid from './intl/sendgrid.json'; @@ -153,10 +158,15 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ xentral as unknown as AdapterDefinition, companiesHouse as unknown as AdapterDefinition, wise as unknown as AdapterDefinition, + activecampaign as unknown as AdapterDefinition, + brevo as unknown as AdapterDefinition, calendly as unknown as AdapterDefinition, + close as unknown as AdapterDefinition, + convertkit as unknown as AdapterDefinition, discordBot as unknown as AdapterDefinition, klaviyo as unknown as AdapterDefinition, lemonsqueezy as unknown as AdapterDefinition, + loops as unknown as AdapterDefinition, mailchimp as unknown as AdapterDefinition, pipedrive as unknown as AdapterDefinition, sendgrid as unknown as AdapterDefinition, diff --git a/packages/backend/src/adapters/intl/activecampaign.json b/packages/backend/src/adapters/intl/activecampaign.json new file mode 100644 index 0000000..cb68270 --- /dev/null +++ b/packages/backend/src/adapters/intl/activecampaign.json @@ -0,0 +1,308 @@ +{ + "slug": "activecampaign", + "name": "ActiveCampaign", + "description": "Drive ActiveCampaign (marketing automation + CRM) from any AI agent: contacts, lists, tags, deals, pipelines, campaigns, automations. 16 tools. Custom Api-Token header auth, per-account API URL.", + "instructions": "This connector uses the ActiveCampaign API v3.\n\n**Setup**:\n1. Sign in to ActiveCampaign → **Settings → Developer**. Two values:\n - **URL** (your account's API URL): `https://{ACCOUNT}.api-us1.com` (or .api-eu1.com, etc.)\n - **Key**: a long alphanumeric API key.\n2. Set `ACTIVECAMPAIGN_API_URL` = the full URL from step 1 (no trailing slash, no `/api/3`).\n3. Set `ACTIVECAMPAIGN_API_KEY` = the key.\n\n**Authentication**: custom header `Api-Token: ${ACTIVECAMPAIGN_API_KEY}`.\n\n**Account-scoped base URL**: every account has its own subdomain. The adapter substitutes `{{ACTIVECAMPAIGN_API_URL}}/api/3` as the base. If you use OAuth2 instead of the API key, the URL is the same.\n\n**Contact model**: every contact has `email` (primary key), plus optional firstName, lastName, phone. Custom fields are 'field values' — separate POSTs to `/fieldValues`. The adapter exposes both contact CRUD and field-value management.\n\n**Lists are static, Tags are flexible**: lists need explicit subscribe operations and trigger double-opt-in by default. Tags are unlimited, free-form labels — easier for segmentation.\n\n**Status values for contact-list memberships**: 1=Active, 2=Unsubscribed, 0=Pending double-opt-in confirmation. Pass these as integers in `contactList.status`.\n\n**Deals are CRM-side**: separate from contacts (one deal links to one primary contact + one organization). Has stage_id (pipeline position), value, currency.\n\n**Pagination**: most list endpoints support `limit` (max 100) + `offset`. `?include=...` side-loads related resources.\n\n**Rate limits**: ~5 req/sec per account on Plus/Pro, higher on Enterprise. On 429, back off.\n\n**Out of scope here**: campaign content composition (use UI), automation creation (UI-only), site tracking JS, deep CRM custom-object schemas, eCommerce integrations.", + "region": "intl", + "category": "marketing-automation", + "icon": "activecampaign", + "docsUrl": "https://developers.activecampaign.com/reference/overview", + "requiredEnvVars": ["ACTIVECAMPAIGN_API_URL", "ACTIVECAMPAIGN_API_KEY"], + "connector": { + "name": "ActiveCampaign v3", + "type": "REST", + "baseUrl": "{{ACTIVECAMPAIGN_API_URL}}/api/3", + "authType": "API_KEY", + "authConfig": { + "headerName": "Api-Token", + "apiKey": "{{ACTIVECAMPAIGN_API_KEY}}" + } + }, + "tools": [ + { + "name": "activecampaign_list_contacts", + "description": "List contacts with optional filters. Each contact returns id, email, firstName, lastName, phone, cdate (created), udate (updated), fieldValues[].", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "Exact email filter." }, + "email_like": { "type": "string", "description": "Substring match on email." }, + "listid": { "type": "integer", "description": "Filter to contacts on this list." }, + "tagid": { "type": "integer", "description": "Filter to contacts with this tag." }, + "status": { "type": "integer", "description": "Filter by membership status: 1=Active, 2=Unsubscribed, 3=Bounced, 0=Pending." }, + "search": { "type": "string", "description": "Free-text search across name + email." }, + "limit": { "type": "integer", "description": "Per page (default 20, max 100)." }, + "offset": { "type": "integer", "description": "Pagination offset." }, + "orders_email": { "type": "string", "description": "Sort by email asc/desc." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/contacts", + "queryParams": { + "email": "$email", + "email_like": "$email_like", + "listid": "$listid", + "tagid": "$tagid", + "status": "$status", + "search": "$search", + "limit": "$limit", + "offset": "$offset", + "orders[email]": "$orders_email" + } + } + }, + { + "name": "activecampaign_get_contact", + "description": "Fetch a contact by ID with relationships (contactLists, contactTags, fieldValues, etc.).", + "parameters": { + "type": "object", + "properties": { + "contactId": { "type": "integer", "description": "Contact ID." }, + "include": { "type": "string", "description": "Side-load: contactLists,contactTags,fieldValues,contactDeals (comma-separated)." } + }, + "required": ["contactId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/contacts/{contactId}", + "queryParams": { "include": "$include" } + } + }, + { + "name": "activecampaign_sync_contact", + "description": "Upsert a contact by email (creates if missing, updates if exists). The preferred way to onboard contacts — avoids the 422 'duplicate' error of POST /contacts.", + "parameters": { + "type": "object", + "properties": { + "contact": { + "type": "object", + "description": "{email, firstName?, lastName?, phone?, fieldValues?: [{field, value}]}. Wrap inside {contact: {...}}." + } + }, + "required": ["contact"] + }, + "endpointMapping": { + "method": "POST", + "path": "/contact/sync", + "bodyMapping": { "contact": "$contact" } + } + }, + { + "name": "activecampaign_update_contact", + "description": "Update an existing contact by ID. Use sync if you don't know whether the contact exists yet.", + "parameters": { + "type": "object", + "properties": { + "contactId": { "type": "integer", "description": "Contact ID." }, + "contact": { + "type": "object", + "description": "{email?, firstName?, lastName?, phone?}. Wrap inside {contact: {...}}." + } + }, + "required": ["contactId", "contact"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/contacts/{contactId}", + "bodyMapping": { "contact": "$contact" } + } + }, + { + "name": "activecampaign_delete_contact", + "description": "Permanently delete a contact (GDPR right-to-be-forgotten). Irreversible.", + "parameters": { + "type": "object", + "properties": { + "contactId": { "type": "integer", "description": "Contact ID." } + }, + "required": ["contactId"] + }, + "endpointMapping": { "method": "DELETE", "path": "/contacts/{contactId}" } + }, + { + "name": "activecampaign_subscribe_contact_to_list", + "description": "Add a contact to a list with the given status. status=1 = Active (skips double-opt-in), 2=Unsubscribed.", + "parameters": { + "type": "object", + "properties": { + "contactList": { + "type": "object", + "description": "{list: LIST_ID, contact: CONTACT_ID, status: 1|2|0}. Wrap inside {contactList: {...}}." + } + }, + "required": ["contactList"] + }, + "endpointMapping": { + "method": "POST", + "path": "/contactLists", + "bodyMapping": { "contactList": "$contactList" } + } + }, + { + "name": "activecampaign_list_lists", + "description": "List all subscriber lists. Each returns id, name, subscriber_count, created.", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Per page." }, + "offset": { "type": "integer", "description": "Pagination offset." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/lists", + "queryParams": { "limit": "$limit", "offset": "$offset" } + } + }, + { + "name": "activecampaign_list_tags", + "description": "List tags. Each tag has id, tag (the name), tagType (contact/template), created.", + "parameters": { + "type": "object", + "properties": { + "search": { "type": "string", "description": "Substring search on tag name." }, + "limit": { "type": "integer", "description": "Per page." }, + "offset": { "type": "integer", "description": "Pagination offset." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/tags", + "queryParams": { "search": "$search", "limit": "$limit", "offset": "$offset" } + } + }, + { + "name": "activecampaign_add_tag_to_contact", + "description": "Attach a tag to a contact (creates a contactTag join).", + "parameters": { + "type": "object", + "properties": { + "contactTag": { + "type": "object", + "description": "{contact: CONTACT_ID, tag: TAG_ID}. Wrap inside {contactTag: {...}}." + } + }, + "required": ["contactTag"] + }, + "endpointMapping": { + "method": "POST", + "path": "/contactTags", + "bodyMapping": { "contactTag": "$contactTag" } + } + }, + { + "name": "activecampaign_remove_tag_from_contact", + "description": "Remove a tag→contact join (NOT delete the tag itself).", + "parameters": { + "type": "object", + "properties": { + "contactTagId": { "type": "integer", "description": "contactTag ID (NOT the tag ID — get it from contactTags list under the contact)." } + }, + "required": ["contactTagId"] + }, + "endpointMapping": { "method": "DELETE", "path": "/contactTags/{contactTagId}" } + }, + { + "name": "activecampaign_list_deals", + "description": "List CRM deals with optional filters.", + "parameters": { + "type": "object", + "properties": { + "stage": { "type": "integer", "description": "Filter by pipeline stage ID." }, + "status": { "type": "integer", "description": "0=Open, 1=Won, 2=Lost." }, + "search": { "type": "string", "description": "Search title." }, + "limit": { "type": "integer", "description": "Per page." }, + "offset": { "type": "integer", "description": "Offset." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/deals", + "queryParams": { + "filters[stage]": "$stage", + "filters[status]": "$status", + "search": "$search", + "limit": "$limit", + "offset": "$offset" + } + } + }, + { + "name": "activecampaign_create_deal", + "description": "Create a CRM deal. Required: title, value (in cents), currency, group (pipeline ID), stage (stage ID), owner (user ID). Contact link via `contact` field.", + "parameters": { + "type": "object", + "properties": { + "deal": { + "type": "object", + "description": "{title, description?, value: cents, currency:'usd', group: PIPELINE_ID, stage: STAGE_ID, owner: USER_ID, contact: CONTACT_ID?, fields?:[{customFieldId, fieldValue}]}. Wrap inside {deal: {...}}." + } + }, + "required": ["deal"] + }, + "endpointMapping": { + "method": "POST", + "path": "/deals", + "bodyMapping": { "deal": "$deal" } + } + }, + { + "name": "activecampaign_update_deal", + "description": "Update a deal — common: change stage_id (move pipeline), set status=1 (won) or 2 (lost), update value.", + "parameters": { + "type": "object", + "properties": { + "dealId": { "type": "integer", "description": "Deal ID." }, + "deal": { + "type": "object", + "description": "{title?, value?, stage?, status?, owner?, fields?}. Wrap inside {deal: {...}}." + } + }, + "required": ["dealId", "deal"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/deals/{dealId}", + "bodyMapping": { "deal": "$deal" } + } + }, + { + "name": "activecampaign_list_pipelines", + "description": "List deal pipelines (each pipeline has multiple stages). Returns id, title, currency, allow_html_descriptions.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/dealGroups" } + }, + { + "name": "activecampaign_list_pipeline_stages", + "description": "List stages, optionally filtered to a pipeline. Each stage has id, title, color, width, order, group (pipeline ID).", + "parameters": { + "type": "object", + "properties": { + "d_groupid": { "type": "integer", "description": "Filter by pipeline ID." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/dealStages", + "queryParams": { "filters[d_groupid]": "$d_groupid" } + } + }, + { + "name": "activecampaign_list_custom_fields", + "description": "List custom fields defined on contacts. Returns id, title, type (text/dropdown/date/...), perstag (the merge tag name).", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Per page." }, + "offset": { "type": "integer", "description": "Pagination offset." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/fields", + "queryParams": { "limit": "$limit", "offset": "$offset" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/activecampaign.live.spec.ts b/packages/backend/src/adapters/intl/activecampaign.live.spec.ts new file mode 100644 index 0000000..98a66a7 --- /dev/null +++ b/packages/backend/src/adapters/intl/activecampaign.live.spec.ts @@ -0,0 +1,22 @@ +import * as adapter from './activecampaign.json'; + +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: Record }; + tools: Array<{ name: string; endpointMapping: { method: string; path: string } }>; +}; + +describe('activecampaign adapter — static spec conformance', () => { + it('uses account-templated base URL', () => { + expect(a.connector.baseUrl).toBe('{{ACTIVECAMPAIGN_API_URL}}/api/3'); + }); + it('uses Api-Token header (not Bearer)', () => { + expect(a.connector.authType).toBe('API_KEY'); + expect(a.connector.authConfig.headerName).toBe('Api-Token'); + expect(a.connector.authConfig.apiKey).toBe('{{ACTIVECAMPAIGN_API_KEY}}'); + }); + it('sync-contact uses POST /contact/sync (singular path) — ActiveCampaign-specific upsert', () => { + const t = a.tools.find((x) => x.name === 'activecampaign_sync_contact')!; + expect(t.endpointMapping.method).toBe('POST'); + expect(t.endpointMapping.path).toBe('/contact/sync'); + }); +}); diff --git a/packages/backend/src/adapters/intl/brevo.json b/packages/backend/src/adapters/intl/brevo.json new file mode 100644 index 0000000..36ce8b2 --- /dev/null +++ b/packages/backend/src/adapters/intl/brevo.json @@ -0,0 +1,355 @@ +{ + "slug": "brevo", + "name": "Brevo", + "description": "Drive Brevo (formerly Sendinblue) — transactional email/SMS, marketing campaigns, contacts and lists — from any AI agent. 14 tools, api-key header auth, EU-based deliverability.", + "instructions": "This connector uses the Brevo REST API v3 (formerly known as Sendinblue API).\n\n**Setup**:\n1. Sign in to https://app.brevo.com → top-right avatar → **SMTP & API → API Keys → Generate a new API key**.\n2. Name it ('AnythingMCP'). It's prefixed `xkeysib-`. Set `BREVO_API_KEY`.\n\n**Authentication**: custom header `api-key: ${BREVO_API_KEY}` (lowercase header name — Brevo-specific). The adapter sets it via API_KEY profile.\n\n**Verified sender**: just like SendGrid, you must verify your sending domain or sender email before transactional email actually delivers (otherwise it lands in spam or is rejected). Brevo → **Senders & IP → Senders → Add a sender** OR domain authentication via SPF/DKIM.\n\n**Transactional vs Marketing**: `brevo_send_transactional_email` is for app-triggered 1-to-1 sends (receipts, password resets). `brevo_create_email_campaign` + `brevo_send_email_campaign_now` are for newsletter-style sends to a list. Pricing tiers are split per channel.\n\n**Contact lists vs folders**: contacts can belong to multiple `lists`. Lists can be organized into `folders` (for UI tidiness; folders have no behavioral meaning). Use `brevo_list_contact_lists` to discover list IDs.\n\n**Custom attributes**: each contact has `attributes` (FIRSTNAME, LASTNAME, plus any you define). Set them in `brevo_create_contact` via the `attributes` object: `{FIRSTNAME:'Jane', LANGUAGE:'fr'}`.\n\n**Pagination**: most list endpoints use `limit` (max 50 typically, 100 for some) + `offset`.\n\n**SMS sending**: also via the API. `brevo_send_transactional_sms` requires a verified sender name (≤11 chars, alphanumeric) AND the recipient phone in international format with `+` prefix.\n\n**Webhooks** out of scope (configured in UI for delivery to your hosted endpoint).\n\n**Rate limits**: ~400 req/sec for transactional email, lower for management endpoints. On 429, `Retry-After` is set.\n\n**Out of scope here**: WhatsApp Business via Brevo (use the dedicated WhatsApp Business connector), CRM/deals (separate Brevo product, not on the same API surface), conversations, automation workflows, attribute creation.", + "region": "intl", + "category": "email", + "icon": "brevo", + "docsUrl": "https://developers.brevo.com/", + "requiredEnvVars": ["BREVO_API_KEY"], + "connector": { + "name": "Brevo v3", + "type": "REST", + "baseUrl": "https://api.brevo.com/v3", + "authType": "API_KEY", + "authConfig": { + "headerName": "api-key", + "apiKey": "{{BREVO_API_KEY}}" + } + }, + "tools": [ + { + "name": "brevo_get_account", + "description": "Return account info: companyName, email, firstName, lastName, address, plan (credits, type, creditsType), relay servers. Health check + plan visibility.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/account" } + }, + { + "name": "brevo_send_transactional_email", + "description": "Send a transactional email. Required: sender + to + (subject AND htmlContent/textContent) OR templateId+params. For templates, design + variables come from Brevo's UI templates.", + "parameters": { + "type": "object", + "properties": { + "sender": { "type": "object", "description": "{email, name?}. email MUST be a verified sender." }, + "to": { "type": "array", "description": "Recipients: [{email, name?}]. Up to 50 per call (more = use bulk endpoint)." }, + "cc": { "type": "array", "description": "Optional CCs: [{email, name?}]." }, + "bcc": { "type": "array", "description": "Optional BCCs." }, + "replyTo": { "type": "object", "description": "{email, name?}." }, + "subject": { "type": "string", "description": "Email subject (required unless using templateId)." }, + "htmlContent": { "type": "string", "description": "HTML body." }, + "textContent": { "type": "string", "description": "Plain-text body. Recommended alongside htmlContent for deliverability." }, + "templateId": { "type": "integer", "description": "Brevo template ID. Omit subject/htmlContent if using." }, + "params": { "type": "object", "description": "Template variables (referenced as {{params.X}} in the template)." }, + "attachment": { "type": "array", "description": "Up to 100 attachments: [{name, content (base64)}] or [{name, url}]." }, + "tags": { "type": "array", "description": "Up to 5 tag strings for stats grouping." }, + "headers": { "type": "object", "description": "Custom headers (e.g. {'X-My-Tag':'abc'})." }, + "scheduledAt": { "type": "string", "description": "ISO 8601 datetime to schedule the send (up to 72h ahead)." } + }, + "required": ["sender", "to"] + }, + "endpointMapping": { + "method": "POST", + "path": "/smtp/email", + "bodyMapping": { + "sender": "$sender", + "to": "$to", + "cc": "$cc", + "bcc": "$bcc", + "replyTo": "$replyTo", + "subject": "$subject", + "htmlContent": "$htmlContent", + "textContent": "$textContent", + "templateId": "$templateId", + "params": "$params", + "attachment": "$attachment", + "tags": "$tags", + "headers": "$headers", + "scheduledAt": "$scheduledAt" + } + } + }, + { + "name": "brevo_send_transactional_sms", + "description": "Send a transactional SMS. Required: sender (≤11 alphanumeric chars, must be pre-approved), recipient (international format with +), content (≤160 chars per part).", + "parameters": { + "type": "object", + "properties": { + "sender": { "type": "string", "description": "Alphanumeric sender name ≤11 chars, must be pre-registered in Brevo." }, + "recipient": { "type": "string", "description": "Phone in international format with leading +, e.g. '+33612345678'." }, + "content": { "type": "string", "description": "SMS body. Per SMSC encoding (GSM-7 = 160 chars/part, UCS-2 = 70 chars/part)." }, + "type": { "type": "string", "description": "transactional or marketing. Default transactional." }, + "tag": { "type": "string", "description": "Stats tag." }, + "webUrl": { "type": "string", "description": "Optional URL for click-tracking." }, + "unicodeEnabled": { "type": "boolean", "description": "true = allow non-GSM chars (emoji, é, ç) — encoding flips to UCS-2." } + }, + "required": ["sender", "recipient", "content"] + }, + "endpointMapping": { + "method": "POST", + "path": "/transactionalSMS/sms", + "bodyMapping": { + "sender": "$sender", + "recipient": "$recipient", + "content": "$content", + "type": "$type", + "tag": "$tag", + "webUrl": "$webUrl", + "unicodeEnabled": "$unicodeEnabled" + } + } + }, + { + "name": "brevo_list_contacts", + "description": "List contacts (paginated). Each contact has id, email, attributes object, createdAt, modifiedAt, listIds[].", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Max per page (default 50, max 1000)." }, + "offset": { "type": "integer", "description": "Pagination offset." }, + "modifiedSince": { "type": "string", "description": "ISO 8601 — only contacts modified after this time." }, + "createdSince": { "type": "string", "description": "ISO 8601." }, + "sort": { "type": "string", "description": "asc or desc by id." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/contacts", + "queryParams": { + "limit": "$limit", + "offset": "$offset", + "modifiedSince": "$modifiedSince", + "createdSince": "$createdSince", + "sort": "$sort" + } + } + }, + { + "name": "brevo_get_contact", + "description": "Fetch a contact by email or ID.", + "parameters": { + "type": "object", + "properties": { + "identifier": { "type": "string", "description": "URL-encoded email address OR numeric contact ID." } + }, + "required": ["identifier"] + }, + "endpointMapping": { "method": "GET", "path": "/contacts/{identifier}" } + }, + { + "name": "brevo_create_contact", + "description": "Create or update a contact (upsert by email). Set `listIds` to add to existing lists. `updateEnabled:true` to update if email already exists (default false → 409 on duplicate).", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "Contact email (required unless using ext_id)." }, + "ext_id": { "type": "string", "description": "External ID if you maintain a separate identifier system." }, + "attributes": { "type": "object", "description": "{FIRSTNAME, LASTNAME, SMS:'+33...', LANGUAGE:'fr', custom...}. Reserved attrs are uppercase." }, + "emailBlacklisted": { "type": "boolean", "description": "true = mark as unsubscribed from emails." }, + "smsBlacklisted": { "type": "boolean", "description": "true = mark as unsubscribed from SMS." }, + "listIds": { "type": "array", "description": "Array of list IDs to add to." }, + "updateEnabled": { "type": "boolean", "description": "true = update existing if email already exists. Default false." }, + "smtpBlacklistSender": { "type": "array", "description": "List of senders to block." } + }, + "required": ["email"] + }, + "endpointMapping": { + "method": "POST", + "path": "/contacts", + "bodyMapping": { + "email": "$email", + "ext_id": "$ext_id", + "attributes": "$attributes", + "emailBlacklisted": "$emailBlacklisted", + "smsBlacklisted": "$smsBlacklisted", + "listIds": "$listIds", + "updateEnabled": "$updateEnabled", + "smtpBlacklistSender": "$smtpBlacklistSender" + } + } + }, + { + "name": "brevo_update_contact", + "description": "Partial update of an existing contact.", + "parameters": { + "type": "object", + "properties": { + "identifier": { "type": "string", "description": "URL-encoded email or numeric ID." }, + "attributes": { "type": "object", "description": "Attributes to update." }, + "emailBlacklisted": { "type": "boolean", "description": "Mark unsubscribed." }, + "smsBlacklisted": { "type": "boolean", "description": "Mark SMS unsubscribed." }, + "listIds": { "type": "array", "description": "Lists to ADD to (use unlinkListIds to remove)." }, + "unlinkListIds": { "type": "array", "description": "Lists to remove from." } + }, + "required": ["identifier"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/contacts/{identifier}", + "bodyMapping": { + "attributes": "$attributes", + "emailBlacklisted": "$emailBlacklisted", + "smsBlacklisted": "$smsBlacklisted", + "listIds": "$listIds", + "unlinkListIds": "$unlinkListIds" + } + } + }, + { + "name": "brevo_list_contact_lists", + "description": "List contact lists. Each list has id, name, folderId, totalSubscribers, totalBlacklisted, createdAt.", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Max per page (default 10, max 50)." }, + "offset": { "type": "integer", "description": "Pagination offset." }, + "sort": { "type": "string", "description": "asc or desc by id." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/contacts/lists", + "queryParams": { "limit": "$limit", "offset": "$offset", "sort": "$sort" } + } + }, + { + "name": "brevo_create_contact_list", + "description": "Create a new list. Each list belongs to a folder (use brevo_list_folders to discover folderId).", + "parameters": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "List name (unique within folder)." }, + "folderId": { "type": "integer", "description": "Parent folder ID." } + }, + "required": ["name", "folderId"] + }, + "endpointMapping": { + "method": "POST", + "path": "/contacts/lists", + "bodyMapping": { "name": "$name", "folderId": "$folderId" } + } + }, + { + "name": "brevo_list_folders", + "description": "List folders (organize contact lists).", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Per page." }, + "offset": { "type": "integer", "description": "Offset." }, + "sort": { "type": "string", "description": "asc or desc." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/contacts/folders", + "queryParams": { "limit": "$limit", "offset": "$offset", "sort": "$sort" } + } + }, + { + "name": "brevo_list_email_campaigns", + "description": "List email campaigns (newsletters). Filter by type (classic/trigger) and status (suspended/archive/sent/queued/draft/inProcess).", + "parameters": { + "type": "object", + "properties": { + "type": { "type": "string", "description": "classic or trigger." }, + "status": { "type": "string", "description": "suspended, archive, sent, queued, draft, inProcess." }, + "startDate": { "type": "string", "description": "ISO 8601 — only campaigns starting after." }, + "endDate": { "type": "string", "description": "ISO 8601." }, + "limit": { "type": "integer", "description": "Max per page (default 500, max 1000)." }, + "offset": { "type": "integer", "description": "Pagination offset." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/emailCampaigns", + "queryParams": { + "type": "$type", + "status": "$status", + "startDate": "$startDate", + "endDate": "$endDate", + "limit": "$limit", + "offset": "$offset" + } + } + }, + { + "name": "brevo_create_email_campaign", + "description": "Create an email campaign (does NOT send — see brevo_send_email_campaign_now to fire). Required: name, subject, sender (verified), htmlContent OR templateId.", + "parameters": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Internal campaign name." }, + "subject": { "type": "string", "description": "Email subject line." }, + "sender": { "type": "object", "description": "{name?, email|id}. email must be verified, OR id of a sender object." }, + "htmlContent": { "type": "string", "description": "HTML body OR omit if using templateId." }, + "textContent": { "type": "string", "description": "Plain-text body." }, + "templateId": { "type": "integer", "description": "Use a saved template." }, + "params": { "type": "object", "description": "Template vars." }, + "recipients": { "type": "object", "description": "{listIds:[N], excludedListIds?:[M], segmentIds?:[K]}." }, + "scheduledAt": { "type": "string", "description": "ISO 8601 — schedule. Omit to leave as draft." }, + "replyTo": { "type": "string", "description": "Reply-To email." }, + "toField": { "type": "string", "description": "Personalized To-field, e.g. '{{FIRSTNAME}} {{LASTNAME}}'." }, + "tag": { "type": "string", "description": "Stats tag." } + }, + "required": ["name", "subject", "sender", "recipients"] + }, + "endpointMapping": { + "method": "POST", + "path": "/emailCampaigns", + "bodyMapping": { + "name": "$name", + "subject": "$subject", + "sender": "$sender", + "htmlContent": "$htmlContent", + "textContent": "$textContent", + "templateId": "$templateId", + "params": "$params", + "recipients": "$recipients", + "scheduledAt": "$scheduledAt", + "replyTo": "$replyTo", + "toField": "$toField", + "tag": "$tag" + } + } + }, + { + "name": "brevo_send_email_campaign_now", + "description": "Send a previously-created email campaign immediately (overrides any scheduledAt). The campaign must pass Brevo's content/sender checks first.", + "parameters": { + "type": "object", + "properties": { + "campaignId": { "type": "integer", "description": "Campaign ID to send now." } + }, + "required": ["campaignId"] + }, + "endpointMapping": { + "method": "POST", + "path": "/emailCampaigns/{campaignId}/sendNow" + } + }, + { + "name": "brevo_get_transactional_email_report", + "description": "Get aggregated transactional email stats for a date range: total requests, delivered, hardBounces, softBounces, opened, clicked, spam.", + "parameters": { + "type": "object", + "properties": { + "days": { "type": "integer", "description": "Last N days (1, 7, 30, 90, 365)." }, + "startDate": { "type": "string", "description": "YYYY-MM-DD (mutually exclusive with days)." }, + "endDate": { "type": "string", "description": "YYYY-MM-DD." }, + "tag": { "type": "string", "description": "Filter by tag." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/smtp/statistics/aggregatedReport", + "queryParams": { + "days": "$days", + "startDate": "$startDate", + "endDate": "$endDate", + "tag": "$tag" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/brevo.live.spec.ts b/packages/backend/src/adapters/intl/brevo.live.spec.ts new file mode 100644 index 0000000..ae142ce --- /dev/null +++ b/packages/backend/src/adapters/intl/brevo.live.spec.ts @@ -0,0 +1,40 @@ +import * as adapter from './brevo.json'; +import { RestEngine } from '../../connectors/engines/rest.engine'; +import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service'; +import { LoginTokenService } from '../../connectors/engines/login-token.service'; + +/** Live: RUN_BREVO_LIVE=1 npx jest src/adapters/intl/brevo.live.spec.ts */ + +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: Record }; + tools: Array<{ name: string; endpointMapping: { method: string; path: string } }>; +}; + +describe('brevo adapter — static spec conformance', () => { + it('api.brevo.com/v3', () => expect(a.connector.baseUrl).toBe('https://api.brevo.com/v3')); + it('lowercase api-key header (Brevo-specific)', () => { + expect(a.connector.authType).toBe('API_KEY'); + expect(a.connector.authConfig.headerName).toBe('api-key'); + }); + it('transactional email goes to /smtp/email', () => { + const t = a.tools.find((x) => x.name === 'brevo_send_transactional_email')!; + expect(t.endpointMapping.path).toBe('/smtp/email'); + }); +}); + +const maybe = process.env.RUN_BREVO_LIVE ? describe : describe.skip; +maybe('brevo adapter — live', () => { + const engine = new RestEngine({} as OAuth2TokenService, {} as LoginTokenService); + it('GET /account 401', async () => { + let err: any; + try { + await engine.execute( + { baseUrl: a.connector.baseUrl, authType: 'API_KEY', authConfig: { headerName: 'api-key', apiKey: 'bogus' } }, + { method: 'GET', path: '/account' }, + {}, + ); + } catch (e) { err = e; } + expect(err).toBeDefined(); + expect(err.response?.status).toBe(401); + }, 30000); +}); diff --git a/packages/backend/src/adapters/intl/close.json b/packages/backend/src/adapters/intl/close.json new file mode 100644 index 0000000..4c2bb05 --- /dev/null +++ b/packages/backend/src/adapters/intl/close.json @@ -0,0 +1,353 @@ +{ + "slug": "close", + "name": "Close CRM", + "description": "Drive Close (sales-team CRM) from any AI agent: leads, contacts, opportunities, activities (calls/emails/SMS/notes), tasks, smart views and custom fields. 16 tools, API-key Basic-auth.", + "instructions": "This connector uses the Close API v1 (https://developer.close.com).\n\n**Setup**:\n1. Sign in to Close → top-right avatar → **Settings → Developer → API Keys → New API Key**.\n2. Name it ('AnythingMCP'), copy the key (starts with `api_`). Set `CLOSE_API_KEY`.\n\n**Authentication**: HTTP Basic auth with username=API_KEY and password empty. The adapter passes `Authorization: Basic base64(API_KEY:)`. Same as Stripe's auth model.\n\n**Lead is the top-level object** — a 'lead' in Close is what other CRMs call an 'account' or 'company': it groups multiple `contacts` (the actual people), `opportunities` (deal-level pipeline records), and `activities` (timeline of calls/emails/notes/SMS). When in doubt, the agent should start with `close_search_leads` (Smart View query) rather than searching contacts directly.\n\n**Custom field IDs**: like Pipedrive, custom fields have opaque IDs. Discover via `close_list_lead_custom_fields` / `close_list_contact_custom_fields` / `close_list_opportunity_custom_fields`. Reference them in writes via `custom.cf_XYZ` keys on the resource.\n\n**Smart Views (saved searches)**: Close's central UX is filtered queries. The API exposes them via `close_search_leads` with a `query` JSON (the same DSL the UI builds). Easiest: build the view in the UI, copy its Smart View ID, then call `close_get_smart_view` to inspect — but for ad-hoc queries the `query` param accepts the JSON directly.\n\n**Activities are typed**: call, email, sms, note, meeting, task_completed, lead_status_change, opportunity_status_change. Use `close_create_note_activity` for plain notes; for calls/emails the SaaS expects you to use Close's built-in dialer / email integration, not POST a fake activity (the agent shouldn't fabricate a sent email).\n\n**Pagination**: cursor-based via `_skip` + `_limit` (max 100 per page) plus `has_more` boolean. Some list endpoints return total counts in `meta`.\n\n**Rate limits**: ~80 requests/sec burst per organization. On 429, back off — `RateLimit-Reset` header is set.\n\n**Out of scope here**: dialer call recording, email sequences (use Close UI), reports/analytics computation, integrations (Mailchimp/Stripe sync), webhook subscription management.", + "region": "intl", + "category": "crm", + "icon": "close", + "docsUrl": "https://developer.close.com/", + "requiredEnvVars": ["CLOSE_API_KEY"], + "connector": { + "name": "Close v1", + "type": "REST", + "baseUrl": "https://api.close.com/api/v1", + "authType": "BASIC_AUTH", + "authConfig": { + "username": "{{CLOSE_API_KEY}}", + "password": "" + } + }, + "tools": [ + { + "name": "close_get_me", + "description": "Return the user the API key belongs to plus the organization context (id, name, plan, addresses). Health check + whoami.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/me/" } + }, + { + "name": "close_list_leads", + "description": "List leads (companies) with optional query filter and pagination. Each lead has display_name, status_id, contacts[], opportunities[], custom fields, addresses, url, description, integration_links.", + "parameters": { + "type": "object", + "properties": { + "query": { "type": "string", "description": "Close query string — e.g. 'has:opportunities lead_status:\"Customer\"' or 'has:contacts'. Empty string returns all." }, + "_fields": { "type": "string", "description": "Comma-separated fields to return (sparse). Default returns all." }, + "_limit": { "type": "integer", "description": "Max per page (default 100, max 100)." }, + "_skip": { "type": "integer", "description": "Pagination offset." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/lead/", + "queryParams": { + "query": "$query", + "_fields": "$_fields", + "_limit": "$_limit", + "_skip": "$_skip" + } + } + }, + { + "name": "close_get_lead", + "description": "Fetch one lead by ID. Returns display_name, contacts[], opportunities[], tasks[], custom fields, all the activity rollups.", + "parameters": { + "type": "object", + "properties": { + "leadId": { "type": "string", "description": "Lead ID (starts with 'lead_')." } + }, + "required": ["leadId"] + }, + "endpointMapping": { "method": "GET", "path": "/lead/{leadId}/" } + }, + { + "name": "close_create_lead", + "description": "Create a new lead (company). Pass contacts inline to create them in one call. Display_name auto-derives from name if omitted.", + "parameters": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Company name (e.g. 'ACME Inc')." }, + "url": { "type": "string", "description": "Website URL." }, + "description": { "type": "string", "description": "Free-text description." }, + "status_id": { "type": "string", "description": "Lead status ID (discover via close_list_lead_statuses)." }, + "addresses": { "type": "array", "description": "Array of {address_1, address_2?, city, state, zipcode, country, label}." }, + "contacts": { "type": "array", "description": "Inline contacts to create: [{name, title?, emails:[{type,email}], phones:[{type,phone}], urls?:[{type,url}]}]." }, + "custom": { "type": "object", "description": "Custom fields keyed by 'cf_XYZ' field IDs." } + }, + "required": ["name"] + }, + "endpointMapping": { + "method": "POST", + "path": "/lead/", + "bodyMapping": { + "name": "$name", + "url": "$url", + "description": "$description", + "status_id": "$status_id", + "addresses": "$addresses", + "contacts": "$contacts", + "custom": "$custom" + } + } + }, + { + "name": "close_update_lead", + "description": "Partial-update a lead. Pass only fields to change.", + "parameters": { + "type": "object", + "properties": { + "leadId": { "type": "string", "description": "Lead ID." }, + "name": { "type": "string", "description": "New name." }, + "url": { "type": "string", "description": "New URL." }, + "description": { "type": "string", "description": "New description." }, + "status_id": { "type": "string", "description": "New status." }, + "addresses": { "type": "array", "description": "Full address array (replaces)." }, + "custom": { "type": "object", "description": "Custom fields to update (cf_XYZ keys)." } + }, + "required": ["leadId"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/lead/{leadId}/", + "bodyMapping": { + "name": "$name", + "url": "$url", + "description": "$description", + "status_id": "$status_id", + "addresses": "$addresses", + "custom": "$custom" + } + } + }, + { + "name": "close_list_contacts", + "description": "List contacts (people) — typically scoped to a lead via the lead_id query param.", + "parameters": { + "type": "object", + "properties": { + "lead_id": { "type": "string", "description": "Filter by lead." }, + "_limit": { "type": "integer", "description": "Max per page." }, + "_skip": { "type": "integer", "description": "Pagination offset." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/contact/", + "queryParams": { + "lead_id": "$lead_id", + "_limit": "$_limit", + "_skip": "$_skip" + } + } + }, + { + "name": "close_create_contact", + "description": "Create a contact on a lead. Required: lead_id + name. Emails/phones are arrays of {type, email|phone}.", + "parameters": { + "type": "object", + "properties": { + "lead_id": { "type": "string", "description": "Parent lead ID." }, + "name": { "type": "string", "description": "Person's full name." }, + "title": { "type": "string", "description": "Job title." }, + "emails": { "type": "array", "description": "[{type:'office'|'mobile'|'home'|'direct'|'fax'|'other', email}]." }, + "phones": { "type": "array", "description": "[{type, phone}]." }, + "urls": { "type": "array", "description": "[{type:'url', url}]." }, + "custom": { "type": "object", "description": "Custom fields (cf_XYZ)." } + }, + "required": ["lead_id", "name"] + }, + "endpointMapping": { + "method": "POST", + "path": "/contact/", + "bodyMapping": { + "lead_id": "$lead_id", + "name": "$name", + "title": "$title", + "emails": "$emails", + "phones": "$phones", + "urls": "$urls", + "custom": "$custom" + } + } + }, + { + "name": "close_list_opportunities", + "description": "List opportunities (deals). Filter by lead_id, status_id, status_type (active/won/lost), date_won range.", + "parameters": { + "type": "object", + "properties": { + "lead_id": { "type": "string", "description": "Filter by lead." }, + "status_id": { "type": "string", "description": "Filter by status." }, + "status_type": { "type": "string", "description": "active, won, lost." }, + "_limit": { "type": "integer", "description": "Per page." }, + "_skip": { "type": "integer", "description": "Pagination offset." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/opportunity/", + "queryParams": { + "lead_id": "$lead_id", + "status_id": "$status_id", + "status_type": "$status_type", + "_limit": "$_limit", + "_skip": "$_skip" + } + } + }, + { + "name": "close_create_opportunity", + "description": "Create an opportunity (deal). Value is in cents (e.g. 100000 = $1000.00). value_period: one_time, monthly, annual.", + "parameters": { + "type": "object", + "properties": { + "lead_id": { "type": "string", "description": "Parent lead ID." }, + "note": { "type": "string", "description": "Opportunity note (description)." }, + "value": { "type": "integer", "description": "Deal value in cents." }, + "value_currency": { "type": "string", "description": "ISO 4217 (USD, EUR, ...)." }, + "value_period": { "type": "string", "description": "one_time, monthly, annual." }, + "status_id": { "type": "string", "description": "Status ID." }, + "confidence": { "type": "integer", "description": "Win probability 0-100." }, + "custom": { "type": "object", "description": "Custom fields." } + }, + "required": ["lead_id"] + }, + "endpointMapping": { + "method": "POST", + "path": "/opportunity/", + "bodyMapping": { + "lead_id": "$lead_id", + "note": "$note", + "value": "$value", + "value_currency": "$value_currency", + "value_period": "$value_period", + "status_id": "$status_id", + "confidence": "$confidence", + "custom": "$custom" + } + } + }, + { + "name": "close_update_opportunity", + "description": "Update an opportunity. Common uses: set status_id (move pipeline stage), update value, set confidence.", + "parameters": { + "type": "object", + "properties": { + "opportunityId": { "type": "string", "description": "Opportunity ID (oppo_XXX)." }, + "status_id": { "type": "string", "description": "New status." }, + "value": { "type": "integer", "description": "New value (cents)." }, + "value_currency": { "type": "string", "description": "New currency." }, + "value_period": { "type": "string", "description": "New period." }, + "confidence": { "type": "integer", "description": "New confidence 0-100." }, + "note": { "type": "string", "description": "New note." }, + "custom": { "type": "object", "description": "Custom fields." } + }, + "required": ["opportunityId"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/opportunity/{opportunityId}/", + "bodyMapping": { + "status_id": "$status_id", + "value": "$value", + "value_currency": "$value_currency", + "value_period": "$value_period", + "confidence": "$confidence", + "note": "$note", + "custom": "$custom" + } + } + }, + { + "name": "close_create_note_activity", + "description": "Log a free-text note on a lead (timeline). Use for AI-generated meeting notes, summaries, observations. Do NOT use for fake emails/calls (those have their own typed activities).", + "parameters": { + "type": "object", + "properties": { + "lead_id": { "type": "string", "description": "Lead to attach the note to." }, + "note": { "type": "string", "description": "Note body. Supports basic plain-text." } + }, + "required": ["lead_id", "note"] + }, + "endpointMapping": { + "method": "POST", + "path": "/activity/note/", + "bodyMapping": { + "lead_id": "$lead_id", + "note": "$note" + } + } + }, + { + "name": "close_list_activities", + "description": "List activities (timeline events) — call, email, sms, note, meeting, etc. Filter by lead_id, contact_id, type, date range.", + "parameters": { + "type": "object", + "properties": { + "lead_id": { "type": "string", "description": "Filter by lead." }, + "contact_id": { "type": "string", "description": "Filter by contact." }, + "_type": { "type": "string", "description": "Activity type: call, email, sms, note, meeting, task_completed, etc." }, + "date_created__gte": { "type": "string", "description": "Created on/after (ISO 8601)." }, + "date_created__lt": { "type": "string", "description": "Created before (ISO 8601)." }, + "_limit": { "type": "integer", "description": "Per page." }, + "_skip": { "type": "integer", "description": "Pagination offset." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/activity/", + "queryParams": { + "lead_id": "$lead_id", + "contact_id": "$contact_id", + "_type": "$_type", + "date_created__gte": "$date_created__gte", + "date_created__lt": "$date_created__lt", + "_limit": "$_limit", + "_skip": "$_skip" + } + } + }, + { + "name": "close_create_task", + "description": "Create a task on a lead. Tasks are reminders for the assignee user.", + "parameters": { + "type": "object", + "properties": { + "lead_id": { "type": "string", "description": "Parent lead ID." }, + "text": { "type": "string", "description": "Task text." }, + "due_date": { "type": "string", "description": "ISO 8601 date YYYY-MM-DD." }, + "assigned_to": { "type": "string", "description": "User ID to assign to (defaults to the API key's user)." } + }, + "required": ["lead_id", "text"] + }, + "endpointMapping": { + "method": "POST", + "path": "/task/", + "bodyMapping": { + "lead_id": "$lead_id", + "text": "$text", + "due_date": "$due_date", + "assigned_to": "$assigned_to" + } + } + }, + { + "name": "close_list_lead_statuses", + "description": "List the lead status values configured on the organization (e.g. 'Potential', 'Qualified', 'Customer', 'Bad Fit').", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/status/lead/" } + }, + { + "name": "close_list_opportunity_statuses", + "description": "List the opportunity (pipeline stage) status values — typically active stages + won/lost terminal states.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/status/opportunity/" } + }, + { + "name": "close_list_lead_custom_fields", + "description": "List the custom fields defined on leads with their IDs (cf_XYZ), types, and pick-list options. Required to compose `custom` objects in create/update.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/custom_fields/lead/" } + } + ] +} diff --git a/packages/backend/src/adapters/intl/close.live.spec.ts b/packages/backend/src/adapters/intl/close.live.spec.ts new file mode 100644 index 0000000..99c834d --- /dev/null +++ b/packages/backend/src/adapters/intl/close.live.spec.ts @@ -0,0 +1,42 @@ +import * as adapter from './close.json'; +import { RestEngine } from '../../connectors/engines/rest.engine'; +import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service'; +import { LoginTokenService } from '../../connectors/engines/login-token.service'; + +/** Live: RUN_CLOSE_LIVE=1 npx jest src/adapters/intl/close.live.spec.ts */ + +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: Record }; + tools: Array<{ name: string; endpointMapping: { method: string; path: string } }>; +}; + +describe('close adapter — static spec conformance', () => { + it('api.close.com/api/v1', () => expect(a.connector.baseUrl).toBe('https://api.close.com/api/v1')); + it('BASIC_AUTH with api key as username, empty password (Stripe-style)', () => { + expect(a.connector.authType).toBe('BASIC_AUTH'); + expect(a.connector.authConfig.username).toBe('{{CLOSE_API_KEY}}'); + expect(a.connector.authConfig.password).toBe(''); + }); + it('all lead endpoints have trailing slash (Close convention)', () => { + for (const t of a.tools) { + expect(t.endpointMapping.path.endsWith('/')).toBe(true); + } + }); +}); + +const maybe = process.env.RUN_CLOSE_LIVE ? describe : describe.skip; +maybe('close adapter — live', () => { + const engine = new RestEngine({} as OAuth2TokenService, {} as LoginTokenService); + it('GET /me/ reaches Close (401 with bogus key)', async () => { + let err: any; + try { + await engine.execute( + { baseUrl: a.connector.baseUrl, authType: 'BASIC_AUTH', authConfig: { username: 'api_bogus', password: '' } }, + { method: 'GET', path: '/me/' }, + {}, + ); + } catch (e) { err = e; } + expect(err).toBeDefined(); + expect(err.response?.status).toBe(401); + }, 30000); +}); diff --git a/packages/backend/src/adapters/intl/convertkit.json b/packages/backend/src/adapters/intl/convertkit.json new file mode 100644 index 0000000..132c650 --- /dev/null +++ b/packages/backend/src/adapters/intl/convertkit.json @@ -0,0 +1,275 @@ +{ + "slug": "convertkit", + "name": "Kit (ConvertKit)", + "description": "Drive Kit (formerly ConvertKit) — creator-focused email marketing — from any AI agent: subscribers, tags, forms, sequences, broadcasts, custom fields. 14 tools, v4 API with API-key Bearer auth.", + "instructions": "This connector uses the Kit v4 API (Kit was renamed from ConvertKit in 2024 — the API still works at api.convertkit.com under v4).\n\n**Setup**:\n1. Sign in to https://app.kit.com → **Advanced Settings → API → Show v4 API key**.\n2. Copy the v4 API key. Set `CONVERTKIT_API_KEY`.\n3. (Optional, only for older v3 endpoints not used here): the v3 API uses an `api_secret` — not needed for this connector.\n\n**Authentication**: `Authorization: Bearer ${CONVERTKIT_API_KEY}`. The adapter handles this via BEARER_TOKEN.\n\n**Subscriber model**: every email is a 'subscriber'. State machine: `active`, `inactive` (manually paused), `bounced`, `complained`, `cancelled`. New subscribers default to `active`. Adding to a sequence does NOT create an active subscriber automatically — they must be active first.\n\n**Tags vs Forms vs Sequences**:\n - **Forms**: capture points (signup widgets, landing pages). When a subscriber fills a form, they're added to the form's list AND tagged with any default tags the form applies.\n - **Tags**: free-form labels. The primary segmentation primitive. A subscriber can have unlimited tags.\n - **Sequences**: timed email courses (used to be 'autoresponder courses'). You subscribe a contact to a sequence and they receive its emails on a schedule.\n - **Broadcasts**: one-time emails to a subset (by tags/forms/sequences). The newsletter primitive.\n\n**Custom fields** are at the account level — define them in the UI, then set values per subscriber via `fields` object in create/update.\n\n**Pagination**: cursor-based. List responses include `pagination: {has_previous_page, has_next_page, start_cursor, end_cursor, per_page}`. Pass `after=$end_cursor` (or `before=$start_cursor`) on the next call. Default per_page=500, max 5000 for some endpoints.\n\n**Rate limits**: 600 requests per minute per API key. On 429, back off — `X-RateLimit-Reset` header tells you when.\n\n**Webhooks** out of scope (Kit supports them, configured in UI to point at your hosted endpoint).\n\n**Out of scope here**: writing automation rules, landing-page builder, commerce / digital products, deliverability headers config.", + "region": "intl", + "category": "email", + "icon": "convertkit", + "docsUrl": "https://developers.kit.com/v4", + "requiredEnvVars": ["CONVERTKIT_API_KEY"], + "connector": { + "name": "Kit v4 (ConvertKit)", + "type": "REST", + "baseUrl": "https://api.convertkit.com/v4", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{CONVERTKIT_API_KEY}}" + } + }, + "tools": [ + { + "name": "convertkit_get_account", + "description": "Return account info (name, plan, primary_email_address, created_at). Health check + plan visibility.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/account" } + }, + { + "name": "convertkit_list_subscribers", + "description": "List subscribers with filters. Each has id, first_name, email_address, state, created_at, fields, tags.", + "parameters": { + "type": "object", + "properties": { + "email_address": { "type": "string", "description": "Exact-match email filter." }, + "tagged_after": { "type": "string", "description": "ISO 8601 — subscribers tagged after this." }, + "tagged_before": { "type": "string", "description": "ISO 8601." }, + "created_after": { "type": "string", "description": "ISO 8601 created lower bound." }, + "created_before": { "type": "string", "description": "ISO 8601." }, + "updated_after": { "type": "string", "description": "ISO 8601 updated lower bound." }, + "updated_before": { "type": "string", "description": "ISO 8601." }, + "sort_field": { "type": "string", "description": "created_at, updated_at, cancelled_at." }, + "sort_order": { "type": "string", "description": "asc or desc." }, + "include_total_count": { "type": "boolean", "description": "If true, response includes total_count (expensive)." }, + "per_page": { "type": "integer", "description": "Max per page (default 500, max 5000)." }, + "after": { "type": "string", "description": "Cursor — return subscribers after this." }, + "before": { "type": "string", "description": "Cursor." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/subscribers", + "queryParams": { + "email_address": "$email_address", + "tagged_after": "$tagged_after", + "tagged_before": "$tagged_before", + "created_after": "$created_after", + "created_before": "$created_before", + "updated_after": "$updated_after", + "updated_before": "$updated_before", + "sort_field": "$sort_field", + "sort_order": "$sort_order", + "include_total_count": "$include_total_count", + "per_page": "$per_page", + "after": "$after", + "before": "$before" + } + } + }, + { + "name": "convertkit_get_subscriber", + "description": "Fetch a subscriber by ID with full fields object and tags[].", + "parameters": { + "type": "object", + "properties": { + "subscriberId": { "type": "integer", "description": "Subscriber numeric ID." } + }, + "required": ["subscriberId"] + }, + "endpointMapping": { "method": "GET", "path": "/subscribers/{subscriberId}" } + }, + { + "name": "convertkit_create_subscriber", + "description": "Create a subscriber. Required: email_address. The subscriber starts in `active` state by default. To onboard with consent for a specific form/sequence, use the form-specific subscribe endpoints (out of scope here) — this endpoint is for direct programmatic add.", + "parameters": { + "type": "object", + "properties": { + "email_address": { "type": "string", "description": "Subscriber email (lowercase recommended)." }, + "first_name": { "type": "string", "description": "First name (optional)." }, + "state": { "type": "string", "description": "active or inactive. Default active." }, + "fields": { "type": "object", "description": "Custom fields object: {LAST_NAME:'Doe', COMPANY:'ACME'} — keys must match fields defined in the UI." } + }, + "required": ["email_address"] + }, + "endpointMapping": { + "method": "POST", + "path": "/subscribers", + "bodyMapping": { + "email_address": "$email_address", + "first_name": "$first_name", + "state": "$state", + "fields": "$fields" + } + } + }, + { + "name": "convertkit_update_subscriber", + "description": "Update a subscriber's email, first_name, or custom fields. To change state (active/inactive), use convertkit_unsubscribe or the subscriber-specific state endpoints.", + "parameters": { + "type": "object", + "properties": { + "subscriberId": { "type": "integer", "description": "Subscriber ID." }, + "email_address": { "type": "string", "description": "New email." }, + "first_name": { "type": "string", "description": "New first name." }, + "fields": { "type": "object", "description": "Custom fields to update." } + }, + "required": ["subscriberId"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/subscribers/{subscriberId}", + "bodyMapping": { + "email_address": "$email_address", + "first_name": "$first_name", + "fields": "$fields" + } + } + }, + { + "name": "convertkit_unsubscribe", + "description": "Mark a subscriber as inactive (Kit's unsubscribe). They stop receiving emails and count against your subscriber quota until manually deleted.", + "parameters": { + "type": "object", + "properties": { + "subscriberId": { "type": "integer", "description": "Subscriber ID." } + }, + "required": ["subscriberId"] + }, + "endpointMapping": { + "method": "POST", + "path": "/subscribers/{subscriberId}/unsubscribe" + } + }, + { + "name": "convertkit_list_tags", + "description": "List all tags. Each has id, name, created_at.", + "parameters": { + "type": "object", + "properties": { + "per_page": { "type": "integer", "description": "Per page." }, + "after": { "type": "string", "description": "Cursor." }, + "before": { "type": "string", "description": "Cursor." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/tags", + "queryParams": { "per_page": "$per_page", "after": "$after", "before": "$before" } + } + }, + { + "name": "convertkit_create_tag", + "description": "Create a tag. Tag names are unique within the account.", + "parameters": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Tag name." } + }, + "required": ["name"] + }, + "endpointMapping": { + "method": "POST", + "path": "/tags", + "bodyMapping": { "name": "$name" } + } + }, + { + "name": "convertkit_tag_subscriber", + "description": "Apply a tag to a subscriber. The subscriber must be `active`.", + "parameters": { + "type": "object", + "properties": { + "tagId": { "type": "integer", "description": "Tag ID." }, + "subscriber_id": { "type": "integer", "description": "Subscriber ID to tag." } + }, + "required": ["tagId", "subscriber_id"] + }, + "endpointMapping": { + "method": "POST", + "path": "/tags/{tagId}/subscribers", + "bodyMapping": { "subscriber_id": "$subscriber_id" } + } + }, + { + "name": "convertkit_remove_tag_from_subscriber", + "description": "Remove a tag from a subscriber.", + "parameters": { + "type": "object", + "properties": { + "tagId": { "type": "integer", "description": "Tag ID." }, + "subscriberId": { "type": "integer", "description": "Subscriber ID." } + }, + "required": ["tagId", "subscriberId"] + }, + "endpointMapping": { + "method": "DELETE", + "path": "/tags/{tagId}/subscribers/{subscriberId}" + } + }, + { + "name": "convertkit_list_forms", + "description": "List signup forms / landing pages. Each form has id, name, type (embed/hosted/modal), format, total_subscriptions.", + "parameters": { + "type": "object", + "properties": { + "type": { "type": "string", "description": "embed, hosted, modal." }, + "per_page": { "type": "integer", "description": "Per page." }, + "after": { "type": "string", "description": "Cursor." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/forms", + "queryParams": { "type": "$type", "per_page": "$per_page", "after": "$after" } + } + }, + { + "name": "convertkit_list_sequences", + "description": "List sequences (drip email courses). Each has id, name, hold (paused?), repeat, created_at.", + "parameters": { + "type": "object", + "properties": { + "per_page": { "type": "integer", "description": "Per page." }, + "after": { "type": "string", "description": "Cursor." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/sequences", + "queryParams": { "per_page": "$per_page", "after": "$after" } + } + }, + { + "name": "convertkit_add_subscriber_to_sequence", + "description": "Enroll a subscriber in a sequence. They'll start receiving its emails on schedule. The subscriber must be `active`.", + "parameters": { + "type": "object", + "properties": { + "sequenceId": { "type": "integer", "description": "Sequence ID." }, + "email_address": { "type": "string", "description": "Subscriber email." } + }, + "required": ["sequenceId", "email_address"] + }, + "endpointMapping": { + "method": "POST", + "path": "/sequences/{sequenceId}/subscribers", + "bodyMapping": { "email_address": "$email_address" } + } + }, + { + "name": "convertkit_list_broadcasts", + "description": "List broadcasts (one-off email sends). Each has id, subject, description, content, public, published_at, send_at, thumbnail_url.", + "parameters": { + "type": "object", + "properties": { + "per_page": { "type": "integer", "description": "Per page." }, + "after": { "type": "string", "description": "Cursor." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/broadcasts", + "queryParams": { "per_page": "$per_page", "after": "$after" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/convertkit.live.spec.ts b/packages/backend/src/adapters/intl/convertkit.live.spec.ts new file mode 100644 index 0000000..801729d --- /dev/null +++ b/packages/backend/src/adapters/intl/convertkit.live.spec.ts @@ -0,0 +1,34 @@ +import * as adapter from './convertkit.json'; +import { RestEngine } from '../../connectors/engines/rest.engine'; +import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service'; +import { LoginTokenService } from '../../connectors/engines/login-token.service'; + +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: Record }; + tools: Array<{ name: string; endpointMapping: { method: string; path: string } }>; +}; + +describe('convertkit adapter — static spec conformance', () => { + it('uses Kit v4 base', () => expect(a.connector.baseUrl).toBe('https://api.convertkit.com/v4')); + it('Bearer auth', () => { + expect(a.connector.authType).toBe('BEARER_TOKEN'); + expect(a.connector.authConfig.token).toBe('{{CONVERTKIT_API_KEY}}'); + }); +}); + +const maybe = process.env.RUN_CONVERTKIT_LIVE ? describe : describe.skip; +maybe('convertkit adapter — live', () => { + const engine = new RestEngine({} as OAuth2TokenService, {} as LoginTokenService); + it('GET /account 401 with bogus', async () => { + let err: any; + try { + await engine.execute( + { baseUrl: a.connector.baseUrl, authType: 'BEARER_TOKEN', authConfig: { token: 'bogus' } }, + { method: 'GET', path: '/account' }, + {}, + ); + } catch (e) { err = e; } + expect(err).toBeDefined(); + expect([401, 403]).toContain(err.response?.status); + }, 30000); +}); diff --git a/packages/backend/src/adapters/intl/loops.json b/packages/backend/src/adapters/intl/loops.json new file mode 100644 index 0000000..c0c4c4b --- /dev/null +++ b/packages/backend/src/adapters/intl/loops.json @@ -0,0 +1,186 @@ +{ + "slug": "loops", + "name": "Loops", + "description": "Send transactional emails, fire product events and manage contacts in Loops (the modern product-email tool for SaaS) from any AI agent. 9 tools, Bearer-token auth.", + "instructions": "This connector uses the Loops v1 REST API (https://loops.so/docs/api-reference).\n\n**Setup**:\n1. Sign in to https://app.loops.so → **Settings → API → Create API key**.\n2. Pick the scope: Public API (recommended for most use) or higher.\n3. Copy the key (prefixed). Set `LOOPS_API_KEY`.\n\n**Authentication**: `Authorization: Bearer ${LOOPS_API_KEY}`.\n\n**Mental model**: Loops is event-driven. You send `events` (like 'user.signed_up', 'subscription.upgraded'), and those events trigger `loops` (the email automations you build in the UI). For transactional sends (password reset, magic link), use `loops_send_transactional_email` with a pre-built transactional template ID.\n\n**Contacts**: identified by `email` (primary key). Use `userGroup` to bucket contacts (free / pro / churned). Custom properties via the `properties` object.\n\n**Transactional templates**: built in the Loops UI under **Transactional**. Each has a `transactionalId` (visible after publishing). Variables in the template (`{{firstName}}`) are filled from the `dataVariables` map on send.\n\n**Idempotency**: events can include an `eventName` + `idempotencyKey` — duplicate calls with the same idempotency key within 24h are deduped.\n\n**Rate limits**: 10 req/sec per API key for sends, higher for reads. On 429, back off.\n\n**Out of scope here**: campaign management (Loops campaigns are visual UI-only), audience filters, attribution reports.", + "region": "intl", + "category": "email", + "icon": "loops", + "docsUrl": "https://loops.so/docs/api-reference", + "requiredEnvVars": ["LOOPS_API_KEY"], + "connector": { + "name": "Loops v1", + "type": "REST", + "baseUrl": "https://app.loops.so/api/v1", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{LOOPS_API_KEY}}" + } + }, + "tools": [ + { + "name": "loops_test_api_key", + "description": "Verify the API key works and return account info (team name, type). Health check.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/api-key" } + }, + { + "name": "loops_find_contact", + "description": "Find a contact by email or userId. Returns the contact object with email, firstName, lastName, userGroup, userId, custom properties, subscribed flag.", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "Email to look up. Either email or userId required." }, + "userId": { "type": "string", "description": "External user ID." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/contacts/find", + "queryParams": { "email": "$email", "userId": "$userId" } + } + }, + { + "name": "loops_create_contact", + "description": "Create a new contact. Required: email. Errors if email already exists (use loops_update_contact or loops_upsert_contact).", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "Contact email." }, + "firstName": { "type": "string", "description": "First name." }, + "lastName": { "type": "string", "description": "Last name." }, + "userGroup": { "type": "string", "description": "Bucket (e.g. 'free', 'pro', 'trial')." }, + "userId": { "type": "string", "description": "External user ID for joining." }, + "subscribed": { "type": "boolean", "description": "Subscribed to marketing emails. Default true." }, + "mailingLists": { "type": "object", "description": "Map of mailing list IDs → boolean (subscribed?)." } + }, + "required": ["email"] + }, + "endpointMapping": { + "method": "POST", + "path": "/contacts/create", + "bodyMapping": { + "email": "$email", + "firstName": "$firstName", + "lastName": "$lastName", + "userGroup": "$userGroup", + "userId": "$userId", + "subscribed": "$subscribed", + "mailingLists": "$mailingLists" + } + } + }, + { + "name": "loops_update_contact", + "description": "Update an existing contact's fields. Errors if contact doesn't exist (use loops_upsert_contact for upsert).", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "Email of contact to update (required)." }, + "firstName": { "type": "string", "description": "New first name." }, + "lastName": { "type": "string", "description": "New last name." }, + "userGroup": { "type": "string", "description": "New bucket." }, + "userId": { "type": "string", "description": "Update external user ID." }, + "subscribed": { "type": "boolean", "description": "Subscribed flag." }, + "mailingLists": { "type": "object", "description": "Mailing list memberships." } + }, + "required": ["email"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/contacts/update", + "bodyMapping": { + "email": "$email", + "firstName": "$firstName", + "lastName": "$lastName", + "userGroup": "$userGroup", + "userId": "$userId", + "subscribed": "$subscribed", + "mailingLists": "$mailingLists" + } + } + }, + { + "name": "loops_delete_contact", + "description": "Permanently delete a contact. GDPR-friendly. Irreversible.", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "Contact email." }, + "userId": { "type": "string", "description": "Or by external user ID." } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/contacts/delete", + "bodyMapping": { "email": "$email", "userId": "$userId" } + } + }, + { + "name": "loops_send_event", + "description": "Send a product/behavioral event to trigger a Loops automation. Required: email (or userId) + eventName. Any eventProperties become merge variables in the triggered emails.", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "Contact email. Either email or userId required." }, + "userId": { "type": "string", "description": "External user ID." }, + "eventName": { "type": "string", "description": "Event name matching a Loops trigger (e.g. 'subscription.upgraded')." }, + "contactProperties": { "type": "object", "description": "Properties to also update on the contact (upsert)." }, + "eventProperties": { "type": "object", "description": "Event-specific properties — usable as {{eventProperties.X}} in triggered emails." }, + "mailingLists": { "type": "object", "description": "Update list memberships in same call." } + }, + "required": ["eventName"] + }, + "endpointMapping": { + "method": "POST", + "path": "/events/send", + "bodyMapping": { + "email": "$email", + "userId": "$userId", + "eventName": "$eventName", + "contactProperties": "$contactProperties", + "eventProperties": "$eventProperties", + "mailingLists": "$mailingLists" + } + } + }, + { + "name": "loops_send_transactional_email", + "description": "Send a transactional email using a pre-built Loops transactional template. The `transactionalId` comes from the Loops UI. Variables in the template are filled from `dataVariables`.", + "parameters": { + "type": "object", + "properties": { + "transactionalId": { "type": "string", "description": "ID of the published transactional template (from Loops UI)." }, + "email": { "type": "string", "description": "Recipient email." }, + "dataVariables": { "type": "object", "description": "Object filling template variables: {firstName:'Jane', resetLink:'...'}." }, + "addToAudience": { "type": "boolean", "description": "If true, also create the recipient as a contact if missing. Default false." }, + "attachments": { "type": "array", "description": "Array of {filename, contentType, data (base64)} attachments. Optional." } + }, + "required": ["transactionalId", "email"] + }, + "endpointMapping": { + "method": "POST", + "path": "/transactional", + "bodyMapping": { + "transactionalId": "$transactionalId", + "email": "$email", + "dataVariables": "$dataVariables", + "addToAudience": "$addToAudience", + "attachments": "$attachments" + } + } + }, + { + "name": "loops_list_mailing_lists", + "description": "List mailing lists configured on the account. Returns each list's id, name, description, isPublic, createdAt.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/lists" } + }, + { + "name": "loops_list_custom_fields", + "description": "List custom contact properties defined on the account, with their data types. Required to know which keys are valid in `contactProperties` / contact updates.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/contacts/customFields" } + } + ] +} diff --git a/packages/backend/src/adapters/intl/loops.live.spec.ts b/packages/backend/src/adapters/intl/loops.live.spec.ts new file mode 100644 index 0000000..366cfa1 --- /dev/null +++ b/packages/backend/src/adapters/intl/loops.live.spec.ts @@ -0,0 +1,34 @@ +import * as adapter from './loops.json'; +import { RestEngine } from '../../connectors/engines/rest.engine'; +import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service'; +import { LoginTokenService } from '../../connectors/engines/login-token.service'; + +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: Record }; + tools: Array<{ name: string; endpointMapping: { method: string; path: string } }>; +}; + +describe('loops adapter — static spec conformance', () => { + it('uses Loops v1 base', () => expect(a.connector.baseUrl).toBe('https://app.loops.so/api/v1')); + it('Bearer auth', () => { + expect(a.connector.authType).toBe('BEARER_TOKEN'); + expect(a.connector.authConfig.token).toBe('{{LOOPS_API_KEY}}'); + }); +}); + +const maybe = process.env.RUN_LOOPS_LIVE ? describe : describe.skip; +maybe('loops adapter — live', () => { + const engine = new RestEngine({} as OAuth2TokenService, {} as LoginTokenService); + it('GET /api-key 401 with bogus', async () => { + let err: any; + try { + await engine.execute( + { baseUrl: a.connector.baseUrl, authType: 'BEARER_TOKEN', authConfig: { token: 'bogus' } }, + { method: 'GET', path: '/api-key' }, + {}, + ); + } catch (e) { err = e; } + expect(err).toBeDefined(); + expect([401, 403]).toContain(err.response?.status); + }, 30000); +}); From 507b61a913a61817b79c5ecb6298bab44f50ab87 Mon Sep 17 00:00:00 2001 From: Matteo Date: Wed, 20 May 2026 12:29:55 +0200 Subject: [PATCH 06/19] connectors: add Copper, Apollo, Salesloft, Outreach, Lemlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 3 — sales/CRM completion. Five connectors covering contact-database enrichment, sales-engagement cadences, and Google-Workspace-native CRM. - Copper CRM: 12 tools — people, companies, opportunities CRUD with custom-field discovery, pipelines, activities, search via POST (Copper-specific convention). Requires 3 auth headers simultaneously (token + appname + user email) — added extraHeaders support to RestEngine API_KEY profile to handle this without polluting per-tool headers. - Apollo.io: 11 tools — people/org search across 275M+ database, enrichment by email/linkedin/id, search of your CRM contacts, cadence enrollment. Instructions document credit-consumption behavior for reveal operations. - Salesloft v2: 11 tools — people/accounts CRUD, cadence list and enrollment via cadence_memberships, call activity logging, user listing. Bearer OAuth2 with short-lived tokens. - Outreach v2: 11 tools — prospects/accounts CRUD with JSON:API envelope, sequences, sequenceStates for enrollment, mailboxes (sending identity), tasks, stages. PATCH for updates. - Lemlist v1: 11 tools — campaigns and their stats, leads in campaign (add/pause/resume/delete), global unsubscribe, webhooks list. BASIC_AUTH with empty username (key as password — Lemlist convention). Engine change: API_KEY auth now supports extraHeaders for vendors like Copper that need multiple fixed headers alongside the key. Backward compatible. Catalog: 60 adapters. All 5 smoke-tested. --- packages/backend/src/adapters/catalog.ts | 10 + .../backend/src/adapters/intl/apollo.json | 260 ++++++++++++++ .../src/adapters/intl/apollo.live.spec.ts | 19 + .../backend/src/adapters/intl/copper.json | 324 ++++++++++++++++++ .../src/adapters/intl/copper.live.spec.ts | 23 ++ .../backend/src/adapters/intl/lemlist.json | 196 +++++++++++ .../src/adapters/intl/lemlist.live.spec.ts | 12 + .../backend/src/adapters/intl/outreach.json | 243 +++++++++++++ .../src/adapters/intl/outreach.live.spec.ts | 13 + .../backend/src/adapters/intl/salesloft.json | 283 +++++++++++++++ .../src/adapters/intl/salesloft.live.spec.ts | 12 + .../src/connectors/engines/rest.engine.ts | 13 +- 12 files changed, 1407 insertions(+), 1 deletion(-) create mode 100644 packages/backend/src/adapters/intl/apollo.json create mode 100644 packages/backend/src/adapters/intl/apollo.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/copper.json create mode 100644 packages/backend/src/adapters/intl/copper.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/lemlist.json create mode 100644 packages/backend/src/adapters/intl/lemlist.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/outreach.json create mode 100644 packages/backend/src/adapters/intl/outreach.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/salesloft.json create mode 100644 packages/backend/src/adapters/intl/salesloft.live.spec.ts diff --git a/packages/backend/src/adapters/catalog.ts b/packages/backend/src/adapters/catalog.ts index 87696b0..4c80c2f 100644 --- a/packages/backend/src/adapters/catalog.ts +++ b/packages/backend/src/adapters/catalog.ts @@ -32,16 +32,21 @@ import * as xentral from './de/xentral.json'; import * as companiesHouse from './gb/companies-house.json'; import * as wise from './gb/wise.json'; import * as activecampaign from './intl/activecampaign.json'; +import * as apollo from './intl/apollo.json'; import * as brevo from './intl/brevo.json'; import * as calendly from './intl/calendly.json'; import * as close from './intl/close.json'; import * as convertkit from './intl/convertkit.json'; +import * as copper from './intl/copper.json'; import * as discordBot from './intl/discord-bot.json'; import * as klaviyo from './intl/klaviyo.json'; +import * as lemlist from './intl/lemlist.json'; import * as lemonsqueezy from './intl/lemonsqueezy.json'; import * as loops from './intl/loops.json'; import * as mailchimp from './intl/mailchimp.json'; +import * as outreach from './intl/outreach.json'; import * as pipedrive from './intl/pipedrive.json'; +import * as salesloft from './intl/salesloft.json'; import * as sendgrid from './intl/sendgrid.json'; import * as sorare from './intl/sorare.json'; import * as telegramBot from './intl/telegram-bot.json'; @@ -159,16 +164,21 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ companiesHouse as unknown as AdapterDefinition, wise as unknown as AdapterDefinition, activecampaign as unknown as AdapterDefinition, + apollo as unknown as AdapterDefinition, brevo as unknown as AdapterDefinition, calendly as unknown as AdapterDefinition, close as unknown as AdapterDefinition, convertkit as unknown as AdapterDefinition, + copper as unknown as AdapterDefinition, discordBot as unknown as AdapterDefinition, klaviyo as unknown as AdapterDefinition, + lemlist as unknown as AdapterDefinition, lemonsqueezy as unknown as AdapterDefinition, loops as unknown as AdapterDefinition, mailchimp as unknown as AdapterDefinition, + outreach as unknown as AdapterDefinition, pipedrive as unknown as AdapterDefinition, + salesloft as unknown as AdapterDefinition, sendgrid as unknown as AdapterDefinition, sorare as unknown as AdapterDefinition, telegramBot as unknown as AdapterDefinition, diff --git a/packages/backend/src/adapters/intl/apollo.json b/packages/backend/src/adapters/intl/apollo.json new file mode 100644 index 0000000..e1df33b --- /dev/null +++ b/packages/backend/src/adapters/intl/apollo.json @@ -0,0 +1,260 @@ +{ + "slug": "apollo", + "name": "Apollo.io", + "description": "Drive Apollo.io (B2B sales platform with contact database + enrichment + sequences) from any AI agent: people search, organization search, contact CRUD, deal stages, sequences. 11 tools.", + "instructions": "This connector uses the Apollo.io API v1 (docs.apollo.io).\n\n**Setup**:\n1. Sign in to Apollo.io → top-right avatar → **Settings → Integrations → API → API Keys → Create New API Key**.\n2. Name it ('AnythingMCP'). Copy the key.\n3. Set `APOLLO_API_KEY`.\n\n**Authentication**: header `X-Api-Key: ${APOLLO_API_KEY}`. (Note: Apollo also supports the api_key query parameter and the legacy `Authorization` header; this connector uses the header form.)\n\n**Search vs Enrich**: Apollo's central feature is searching its 275M+ people database (`/v1/mixed_people/search`). `match` endpoints enrich a known person/company by email/domain. People search is the entry point for prospecting workflows.\n\n**Credits model**: every `enrich`, `mixed_people/search` result, and reveal-email/phone call costs API credits. Read your plan's credit balance before bulk operations. Apollo returns `429 quota exceeded` (NOT a normal 429) when credits run out.\n\n**Email reveal mechanics**: searching returns 'unrevealed' contacts with email=null. To get the email, call `apollo_people_enrich` for that specific person — that consumes credits. Plan accordingly.\n\n**Sequences**: Apollo's outreach engine. Pulling contacts via search → adding them to a sequence is the most common write flow. Cadences are configured in the UI; the API just adds/removes contacts.\n\n**Filters**: people search accepts many filters — person_titles, person_locations, organization_num_employees_ranges, organization_industry_tag_ids, contact_email_status, etc. The adapter exposes the most common.\n\n**Pagination**: 1-based `page` parameter, `per_page` max 100. `pagination.total_pages` tells you when to stop.\n\n**Rate limits**: per plan — varies wildly. Enterprise has multi-thousand req/min, Starter much lower. On 429 honor `Retry-After`.\n\n**Out of scope here**: lead-scoring config, sequence template editing, conversation intelligence, plays/dispositions, billing.", + "region": "intl", + "category": "crm", + "icon": "apollo", + "docsUrl": "https://docs.apollo.io/reference/", + "requiredEnvVars": ["APOLLO_API_KEY"], + "connector": { + "name": "Apollo.io v1", + "type": "REST", + "baseUrl": "https://api.apollo.io/v1", + "authType": "API_KEY", + "authConfig": { + "headerName": "X-Api-Key", + "apiKey": "{{APOLLO_API_KEY}}" + } + }, + "tools": [ + { + "name": "apollo_search_people", + "description": "Search Apollo's 275M+ contact database. Apollo's flagship feature. Returns paginated people matches with title, organization, location, seniority — email is null unless you enrich (consumes credits). Use filters aggressively to narrow before enriching.", + "parameters": { + "type": "object", + "properties": { + "q_keywords": { "type": "string", "description": "Free-text search across name, title, company." }, + "person_titles": { "type": "array", "description": "Array of title strings — e.g. ['CTO','VP Engineering']." }, + "person_locations": { "type": "array", "description": "Array of location strings — e.g. ['San Francisco, CA','Germany']." }, + "person_seniorities": { "type": "array", "description": "founder, c_suite, partner, vp, head, director, manager, senior, entry, intern." }, + "organization_num_employees_ranges": { "type": "array", "description": "Ranges like ['1,10','11,50','51,200','201,500','501,1000','1001,5000','5001,10000','10001'] (last = '10001+')." }, + "organization_industry_tag_ids": { "type": "array", "description": "Apollo industry tag IDs (discover via Apollo UI's industry filter)." }, + "contact_email_status": { "type": "array", "description": "Filter by email status: ['verified','likely_to_engage','unverified']." }, + "page": { "type": "integer", "description": "1-based page." }, + "per_page": { "type": "integer", "description": "Per page (default 25, max 100)." } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/mixed_people/search", + "bodyMapping": { + "q_keywords": "$q_keywords", + "person_titles": "$person_titles", + "person_locations": "$person_locations", + "person_seniorities": "$person_seniorities", + "organization_num_employees_ranges": "$organization_num_employees_ranges", + "organization_industry_tag_ids": "$organization_industry_tag_ids", + "contact_email_status": "$contact_email_status", + "page": "$page", + "per_page": "$per_page" + } + } + }, + { + "name": "apollo_people_enrich", + "description": "Enrich a person — provide ONE of email / first_name+last_name+organization_name / linkedin_url / id. Returns full profile including verified email (and often phone). CONSUMES CREDITS.", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "Email to lookup." }, + "first_name": { "type": "string", "description": "First name (combine with last_name + organization_name)." }, + "last_name": { "type": "string", "description": "Last name." }, + "organization_name": { "type": "string", "description": "Company name." }, + "linkedin_url": { "type": "string", "description": "LinkedIn profile URL." }, + "id": { "type": "string", "description": "Apollo person ID (24-char hex)." }, + "reveal_personal_emails": { "type": "boolean", "description": "If true, also reveal personal email if found (extra credit cost)." }, + "reveal_phone_number": { "type": "boolean", "description": "If true, reveal mobile/work phone (extra credits, slower 401 if your plan doesn't include phones)." } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/people/match", + "bodyMapping": { + "email": "$email", + "first_name": "$first_name", + "last_name": "$last_name", + "organization_name": "$organization_name", + "linkedin_url": "$linkedin_url", + "id": "$id", + "reveal_personal_emails": "$reveal_personal_emails", + "reveal_phone_number": "$reveal_phone_number" + } + } + }, + { + "name": "apollo_search_organizations", + "description": "Search Apollo's company database. Returns paginated organizations with industry, employee count, technologies used, funding, location.", + "parameters": { + "type": "object", + "properties": { + "q_organization_keyword_tags": { "type": "array", "description": "Keyword tags for the company (industry/topic)." }, + "q_organization_name": { "type": "string", "description": "Name substring." }, + "organization_locations": { "type": "array", "description": "Location strings." }, + "organization_num_employees_ranges": { "type": "array", "description": "Employee count ranges." }, + "organization_industry_tag_ids": { "type": "array", "description": "Industry tag IDs." }, + "technology_uids": { "type": "array", "description": "Apollo tech UIDs (e.g. 'salesforce', 'hubspot' — find via tech filter in UI)." }, + "page": { "type": "integer", "description": "Page." }, + "per_page": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/mixed_companies/search", + "bodyMapping": { + "q_organization_keyword_tags": "$q_organization_keyword_tags", + "q_organization_name": "$q_organization_name", + "organization_locations": "$organization_locations", + "organization_num_employees_ranges": "$organization_num_employees_ranges", + "organization_industry_tag_ids": "$organization_industry_tag_ids", + "technology_uids": "$technology_uids", + "page": "$page", + "per_page": "$per_page" + } + } + }, + { + "name": "apollo_organization_enrich", + "description": "Enrich an organization — provide domain (most common) OR id. Returns full org profile with technographics, funding, employee count, contact patterns.", + "parameters": { + "type": "object", + "properties": { + "domain": { "type": "string", "description": "Company domain (e.g. 'acme.com')." } + }, + "required": ["domain"] + }, + "endpointMapping": { + "method": "GET", + "path": "/organizations/enrich", + "queryParams": { "domain": "$domain" } + } + }, + { + "name": "apollo_search_contacts", + "description": "Search YOUR Apollo CRM contacts (people already in your account — NOT the public database). Returns contacts you've enriched or added with the labels and stage info.", + "parameters": { + "type": "object", + "properties": { + "q_keywords": { "type": "string", "description": "Free-text search." }, + "contact_stage_ids": { "type": "array", "description": "Filter by contact stage IDs." }, + "sort_by_field": { "type": "string", "description": "contact_last_activity_date, created_at, name, etc." }, + "sort_ascending": { "type": "boolean", "description": "Sort direction." }, + "page": { "type": "integer", "description": "Page." }, + "per_page": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/contacts/search", + "bodyMapping": { + "q_keywords": "$q_keywords", + "contact_stage_ids": "$contact_stage_ids", + "sort_by_field": "$sort_by_field", + "sort_ascending": "$sort_ascending", + "page": "$page", + "per_page": "$per_page" + } + } + }, + { + "name": "apollo_create_contact", + "description": "Create or update a contact in YOUR Apollo CRM. Pass first_name + last_name + email at minimum. label_names auto-creates labels.", + "parameters": { + "type": "object", + "properties": { + "first_name": { "type": "string", "description": "First name." }, + "last_name": { "type": "string", "description": "Last name." }, + "email": { "type": "string", "description": "Email." }, + "title": { "type": "string", "description": "Job title." }, + "account_id": { "type": "string", "description": "Apollo account/org ID." }, + "label_names": { "type": "array", "description": "Array of label name strings." }, + "contact_stage_id": { "type": "string", "description": "Stage ID." } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/contacts", + "bodyMapping": { + "first_name": "$first_name", + "last_name": "$last_name", + "email": "$email", + "title": "$title", + "account_id": "$account_id", + "label_names": "$label_names", + "contact_stage_id": "$contact_stage_id" + } + } + }, + { + "name": "apollo_update_contact", + "description": "Update an Apollo CRM contact.", + "parameters": { + "type": "object", + "properties": { + "contactId": { "type": "string", "description": "Contact ID." }, + "first_name": { "type": "string", "description": "New first name." }, + "last_name": { "type": "string", "description": "New last name." }, + "email": { "type": "string", "description": "New email." }, + "title": { "type": "string", "description": "New title." }, + "label_names": { "type": "array", "description": "Replace labels." }, + "contact_stage_id": { "type": "string", "description": "Move to a different stage." } + }, + "required": ["contactId"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/contacts/{contactId}", + "bodyMapping": { + "first_name": "$first_name", + "last_name": "$last_name", + "email": "$email", + "title": "$title", + "label_names": "$label_names", + "contact_stage_id": "$contact_stage_id" + } + } + }, + { + "name": "apollo_list_contact_stages", + "description": "List your account's contact stages (Cold/Working/Replied/Customer/etc.). Required for composing contact_stage_id values.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/contact_stages" } + }, + { + "name": "apollo_list_email_sequences", + "description": "List outreach sequences. Each sequence has id, name, num_steps, status (active/paused/archived), num_active_steps.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/emailer_campaigns" } + }, + { + "name": "apollo_add_contact_to_sequence", + "description": "Enroll a contact in an outreach sequence. Required: contact_ids (array, max 10) + emailer_campaign_id + send_email_from_email_account_id. The send-from email account must be already configured in Apollo.", + "parameters": { + "type": "object", + "properties": { + "emailer_campaign_id": { "type": "string", "description": "Sequence ID." }, + "contact_ids": { "type": "array", "description": "Array of contact IDs (max 10 per call)." }, + "send_email_from_email_account_id": { "type": "string", "description": "Email account ID to send from (set up in Apollo UI)." }, + "sequence_no_email": { "type": "boolean", "description": "If true, the contact's email is unverified — Apollo skips email-step actions but runs other step types (LinkedIn, calls)." } + }, + "required": ["emailer_campaign_id", "contact_ids", "send_email_from_email_account_id"] + }, + "endpointMapping": { + "method": "POST", + "path": "/emailer_campaigns/{emailer_campaign_id}/add_contact_ids", + "bodyMapping": { + "contact_ids": "$contact_ids", + "send_email_from_email_account_id": "$send_email_from_email_account_id", + "sequence_no_email": "$sequence_no_email" + } + } + }, + { + "name": "apollo_list_email_accounts", + "description": "List email accounts connected to Apollo (used as sending identities for sequences). Returns id, email, user_id, active flag.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/email_accounts" } + } + ] +} diff --git a/packages/backend/src/adapters/intl/apollo.live.spec.ts b/packages/backend/src/adapters/intl/apollo.live.spec.ts new file mode 100644 index 0000000..236d680 --- /dev/null +++ b/packages/backend/src/adapters/intl/apollo.live.spec.ts @@ -0,0 +1,19 @@ +import * as adapter from './apollo.json'; + +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: Record }; + tools: Array<{ name: string; endpointMapping: { method: string; path: string } }>; +}; + +describe('apollo adapter — static spec conformance', () => { + it('api.apollo.io/v1', () => expect(a.connector.baseUrl).toBe('https://api.apollo.io/v1')); + it('X-Api-Key header', () => { + expect(a.connector.authType).toBe('API_KEY'); + expect(a.connector.authConfig.headerName).toBe('X-Api-Key'); + }); + it('people search uses POST /mixed_people/search', () => { + const t = a.tools.find((x) => x.name === 'apollo_search_people')!; + expect(t.endpointMapping.method).toBe('POST'); + expect(t.endpointMapping.path).toBe('/mixed_people/search'); + }); +}); diff --git a/packages/backend/src/adapters/intl/copper.json b/packages/backend/src/adapters/intl/copper.json new file mode 100644 index 0000000..54bf171 --- /dev/null +++ b/packages/backend/src/adapters/intl/copper.json @@ -0,0 +1,324 @@ +{ + "slug": "copper", + "name": "Copper CRM", + "description": "Drive Copper (Google Workspace-native CRM) from any AI agent: people, companies, opportunities, leads, activities, tasks. 12 tools, API-token auth with email + token + appname headers.", + "instructions": "This connector uses the Copper REST API v1 (developer.copper.com).\n\n**Setup**:\n1. Sign in to Copper → top-right avatar → **Settings → Integrations → API Keys → Generate API Key**.\n2. Copy: the key + the email of the user it belongs to.\n3. Set:\n - `COPPER_API_KEY` = generated key\n - `COPPER_USER_EMAIL` = the email of the API key owner\n - `COPPER_APP_NAME` = a label for your integration (e.g. `anythingmcp`)\n\n**Authentication**: three custom headers — `X-PW-AccessToken: ${COPPER_API_KEY}`, `X-PW-Application: developer_api`, `X-PW-UserEmail: ${COPPER_USER_EMAIL}`. Quirky but it's Copper's convention.\n\n**Pluralization quirk**: most endpoints use the PLURAL form (`/people`, `/companies`, `/opportunities`, `/leads`). Single-record GETs use `/people/{id}`. Search uses `POST /people/search` (yes, POST for read — Copper-specific).\n\n**People = contacts**, **Leads = unqualified prospects** (separate object before they become a person attached to a company). A 'company' is one record; a 'person' belongs to one company.\n\n**Pagination**: search endpoints use `page_number` (1-based) + `page_size` (max 200). The response is a flat array; total count is NOT returned by default.\n\n**Custom fields**: `custom_fields` on every record — array of `{custom_field_definition_id, value}`. Discover IDs via `copper_list_custom_field_definitions`.\n\n**Rate limits**: ~10 req/sec per account. On 429, back off.\n\n**Out of scope here**: webhooks, projects, automations, custom activity types beyond standard, file attachments.", + "region": "intl", + "category": "crm", + "icon": "copper", + "docsUrl": "https://developer.copper.com/", + "requiredEnvVars": ["COPPER_API_KEY", "COPPER_USER_EMAIL", "COPPER_APP_NAME"], + "connector": { + "name": "Copper v1", + "type": "REST", + "baseUrl": "https://api.copper.com/developer_api/v1", + "authType": "API_KEY", + "authConfig": { + "headerName": "X-PW-AccessToken", + "apiKey": "{{COPPER_API_KEY}}", + "extraHeaders": { + "X-PW-Application": "developer_api", + "X-PW-UserEmail": "{{COPPER_USER_EMAIL}}" + } + } + }, + "tools": [ + { + "name": "copper_search_people", + "description": "Search people (contacts). POST a filter body — accepts page_number/page_size/name/email_domains/contact_type_ids/tags/assignee_ids/age, etc. Returns list of person records.", + "parameters": { + "type": "object", + "properties": { + "page_number": { "type": "integer", "description": "1-based page." }, + "page_size": { "type": "integer", "description": "Per page (max 200)." }, + "sort_by": { "type": "string", "description": "name, date_modified, date_created." }, + "sort_direction": { "type": "string", "description": "asc or desc." }, + "name": { "type": "string", "description": "Filter by name substring." }, + "emails": { "type": "array", "description": "Array of email substrings to match." }, + "assignee_ids": { "type": "array", "description": "Filter by assignee user IDs." }, + "tags": { "type": "array", "description": "Filter by tag names." } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/people/search", + "bodyMapping": { + "page_number": "$page_number", + "page_size": "$page_size", + "sort_by": "$sort_by", + "sort_direction": "$sort_direction", + "name": "$name", + "emails": "$emails", + "assignee_ids": "$assignee_ids", + "tags": "$tags" + } + } + }, + { + "name": "copper_get_person", + "description": "Fetch a person by ID with full fields including emails, phone_numbers, address, custom_fields, websites, socials, company info, assignee.", + "parameters": { + "type": "object", + "properties": { + "personId": { "type": "integer", "description": "Person numeric ID." } + }, + "required": ["personId"] + }, + "endpointMapping": { "method": "GET", "path": "/people/{personId}" } + }, + { + "name": "copper_create_person", + "description": "Create a new person. Required: name. emails/phone_numbers are arrays of {email, category} / {number, category}.", + "parameters": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Full name." }, + "emails": { "type": "array", "description": "[{email, category:'work'|'personal'|'other'}]." }, + "phone_numbers": { "type": "array", "description": "[{number, category:'work'|'mobile'|'home'|'other'}]." }, + "title": { "type": "string", "description": "Job title." }, + "company_id": { "type": "integer", "description": "Linked company ID." }, + "assignee_id": { "type": "integer", "description": "User ID of assignee." }, + "tags": { "type": "array", "description": "Array of tag name strings." }, + "custom_fields": { "type": "array", "description": "[{custom_field_definition_id, value}]." }, + "address": { "type": "object", "description": "{street, city, state, postal_code, country}." } + }, + "required": ["name"] + }, + "endpointMapping": { + "method": "POST", + "path": "/people", + "bodyMapping": { + "name": "$name", + "emails": "$emails", + "phone_numbers": "$phone_numbers", + "title": "$title", + "company_id": "$company_id", + "assignee_id": "$assignee_id", + "tags": "$tags", + "custom_fields": "$custom_fields", + "address": "$address" + } + } + }, + { + "name": "copper_update_person", + "description": "Update a person. Partial — only pass fields to change.", + "parameters": { + "type": "object", + "properties": { + "personId": { "type": "integer", "description": "Person ID." }, + "name": { "type": "string", "description": "New name." }, + "emails": { "type": "array", "description": "Replace emails array." }, + "phone_numbers": { "type": "array", "description": "Replace phones." }, + "title": { "type": "string", "description": "New title." }, + "company_id": { "type": "integer", "description": "Reassign company." }, + "assignee_id": { "type": "integer", "description": "Reassign owner." }, + "tags": { "type": "array", "description": "Replace tags." }, + "custom_fields": { "type": "array", "description": "Custom fields." } + }, + "required": ["personId"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/people/{personId}", + "bodyMapping": { + "name": "$name", + "emails": "$emails", + "phone_numbers": "$phone_numbers", + "title": "$title", + "company_id": "$company_id", + "assignee_id": "$assignee_id", + "tags": "$tags", + "custom_fields": "$custom_fields" + } + } + }, + { + "name": "copper_search_companies", + "description": "Search companies (organizations). POST filter body — name, contact_type_ids, tags, assignee_ids, etc.", + "parameters": { + "type": "object", + "properties": { + "page_number": { "type": "integer", "description": "1-based page." }, + "page_size": { "type": "integer", "description": "Per page." }, + "name": { "type": "string", "description": "Name substring." }, + "tags": { "type": "array", "description": "Tag filter." }, + "assignee_ids": { "type": "array", "description": "Owner filter." } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/companies/search", + "bodyMapping": { + "page_number": "$page_number", + "page_size": "$page_size", + "name": "$name", + "tags": "$tags", + "assignee_ids": "$assignee_ids" + } + } + }, + { + "name": "copper_create_company", + "description": "Create a company. Required: name.", + "parameters": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Company name." }, + "assignee_id": { "type": "integer", "description": "Owner user ID." }, + "tags": { "type": "array", "description": "Tag names." }, + "address": { "type": "object", "description": "{street, city, state, postal_code, country}." }, + "email_domain": { "type": "string", "description": "Primary domain (auto-link people by email)." }, + "websites": { "type": "array", "description": "[{url, category:'work'|'personal'|'other'}]." }, + "phone_numbers": { "type": "array", "description": "[{number, category}]." }, + "custom_fields": { "type": "array", "description": "[{custom_field_definition_id, value}]." }, + "details": { "type": "string", "description": "Free-text description." } + }, + "required": ["name"] + }, + "endpointMapping": { + "method": "POST", + "path": "/companies", + "bodyMapping": { + "name": "$name", + "assignee_id": "$assignee_id", + "tags": "$tags", + "address": "$address", + "email_domain": "$email_domain", + "websites": "$websites", + "phone_numbers": "$phone_numbers", + "custom_fields": "$custom_fields", + "details": "$details" + } + } + }, + { + "name": "copper_search_opportunities", + "description": "Search opportunities (deals). Filter by name, status (open/won/lost/abandoned), pipeline_stage_ids, assignee.", + "parameters": { + "type": "object", + "properties": { + "page_number": { "type": "integer", "description": "1-based page." }, + "page_size": { "type": "integer", "description": "Per page." }, + "name": { "type": "string", "description": "Name substring." }, + "status_ids": { "type": "array", "description": "Status IDs: 0=open, 1=won, 2=lost, 3=abandoned." }, + "pipeline_stage_ids": { "type": "array", "description": "Stage ID filter." }, + "assignee_ids": { "type": "array", "description": "Owner filter." } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/opportunities/search", + "bodyMapping": { + "page_number": "$page_number", + "page_size": "$page_size", + "name": "$name", + "status_ids": "$status_ids", + "pipeline_stage_ids": "$pipeline_stage_ids", + "assignee_ids": "$assignee_ids" + } + } + }, + { + "name": "copper_create_opportunity", + "description": "Create an opportunity (deal). Required: name + primary_contact_id + pipeline_id + pipeline_stage_id. Monetary value in `monetary_value` (cents).", + "parameters": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Deal name." }, + "primary_contact_id": { "type": "integer", "description": "Person ID of the main contact." }, + "company_id": { "type": "integer", "description": "Linked company ID." }, + "pipeline_id": { "type": "integer", "description": "Pipeline ID (use copper_list_pipelines)." }, + "pipeline_stage_id": { "type": "integer", "description": "Stage ID within the pipeline." }, + "assignee_id": { "type": "integer", "description": "Owner user ID." }, + "monetary_value": { "type": "number", "description": "Deal value (uses account's currency)." }, + "status": { "type": "string", "description": "Open, Won, Lost, Abandoned." }, + "tags": { "type": "array", "description": "Tag names." }, + "custom_fields": { "type": "array", "description": "[{custom_field_definition_id, value}]." }, + "close_date": { "type": "string", "description": "Expected close date MM/DD/YYYY." } + }, + "required": ["name", "primary_contact_id"] + }, + "endpointMapping": { + "method": "POST", + "path": "/opportunities", + "bodyMapping": { + "name": "$name", + "primary_contact_id": "$primary_contact_id", + "company_id": "$company_id", + "pipeline_id": "$pipeline_id", + "pipeline_stage_id": "$pipeline_stage_id", + "assignee_id": "$assignee_id", + "monetary_value": "$monetary_value", + "status": "$status", + "tags": "$tags", + "custom_fields": "$custom_fields", + "close_date": "$close_date" + } + } + }, + { + "name": "copper_update_opportunity", + "description": "Update a deal — common: pipeline_stage_id (move stage), status (close as Won/Lost).", + "parameters": { + "type": "object", + "properties": { + "opportunityId": { "type": "integer", "description": "Opportunity ID." }, + "name": { "type": "string", "description": "New name." }, + "pipeline_stage_id": { "type": "integer", "description": "New stage." }, + "status": { "type": "string", "description": "Open / Won / Lost / Abandoned." }, + "monetary_value": { "type": "number", "description": "New value." }, + "assignee_id": { "type": "integer", "description": "Reassign owner." } + }, + "required": ["opportunityId"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/opportunities/{opportunityId}", + "bodyMapping": { + "name": "$name", + "pipeline_stage_id": "$pipeline_stage_id", + "status": "$status", + "monetary_value": "$monetary_value", + "assignee_id": "$assignee_id" + } + } + }, + { + "name": "copper_list_pipelines", + "description": "List pipelines with their stages. Returns id, name, stages[{id, name, win_probability}]. Required to compose create-opportunity calls.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/pipelines" } + }, + { + "name": "copper_list_custom_field_definitions", + "description": "List custom-field definitions for all object types. Returns id, name, data_type, available_on (people/companies/opportunities/leads). Required for composing custom_fields arrays.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/custom_field_definitions" } + }, + { + "name": "copper_create_activity", + "description": "Log an activity (note/call/meeting) on a record. Activity type IDs come from /activity_types. Most common: type=0 (user) for notes.", + "parameters": { + "type": "object", + "properties": { + "parent": { "type": "object", "description": "{type:'person'|'company'|'opportunity'|'lead', id: ID}." }, + "type": { "type": "object", "description": "{category:'user'|'system', id: ACTIVITY_TYPE_ID}. Discover via /activity_types." }, + "details": { "type": "string", "description": "Activity body / note." }, + "activity_date": { "type": "integer", "description": "Unix timestamp." } + }, + "required": ["parent", "type", "details"] + }, + "endpointMapping": { + "method": "POST", + "path": "/activities", + "bodyMapping": { + "parent": "$parent", + "type": "$type", + "details": "$details", + "activity_date": "$activity_date" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/copper.live.spec.ts b/packages/backend/src/adapters/intl/copper.live.spec.ts new file mode 100644 index 0000000..20ccfeb --- /dev/null +++ b/packages/backend/src/adapters/intl/copper.live.spec.ts @@ -0,0 +1,23 @@ +import * as adapter from './copper.json'; + +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: any }; + tools: Array<{ name: string; endpointMapping: { method: string; path: string } }>; +}; + +describe('copper adapter — static spec conformance', () => { + it('uses api.copper.com/developer_api/v1', () => { + expect(a.connector.baseUrl).toBe('https://api.copper.com/developer_api/v1'); + }); + it('uses X-PW-AccessToken header + extraHeaders for X-PW-Application and X-PW-UserEmail', () => { + expect(a.connector.authType).toBe('API_KEY'); + expect(a.connector.authConfig.headerName).toBe('X-PW-AccessToken'); + expect(a.connector.authConfig.extraHeaders['X-PW-Application']).toBe('developer_api'); + expect(a.connector.authConfig.extraHeaders['X-PW-UserEmail']).toBe('{{COPPER_USER_EMAIL}}'); + }); + it('search uses POST (Copper-specific — read via POST)', () => { + const t = a.tools.find((x) => x.name === 'copper_search_people')!; + expect(t.endpointMapping.method).toBe('POST'); + expect(t.endpointMapping.path).toBe('/people/search'); + }); +}); diff --git a/packages/backend/src/adapters/intl/lemlist.json b/packages/backend/src/adapters/intl/lemlist.json new file mode 100644 index 0000000..6baf261 --- /dev/null +++ b/packages/backend/src/adapters/intl/lemlist.json @@ -0,0 +1,196 @@ +{ + "slug": "lemlist", + "name": "Lemlist", + "description": "Drive Lemlist (cold email + multichannel outreach) from any AI agent: campaigns, leads (in-campaign prospects), team members, hooks. 11 tools, API-key Basic auth.", + "instructions": "This connector uses the Lemlist API v1 (developer.lemlist.com).\n\n**Setup**:\n1. Sign in to Lemlist → top-right avatar → **Settings → Integrations → API → Get my API key**.\n2. Copy the key. Set `LEMLIST_API_KEY`.\n\n**Authentication**: HTTP Basic Auth — `Authorization: Basic base64('':${LEMLIST_API_KEY})` — username is empty, password is the API key. (Yes, swapped from the usual: empty username, key as password.) The adapter handles this via BASIC_AUTH with username='' and password=key.\n\n**Campaign model**: Lemlist's central unit. A campaign has a sequence of steps (email + waits + LinkedIn touches). You add **leads** (prospects) to a campaign — the campaign then drips touchpoints to each lead.\n\n**Lead state**: a lead in a campaign has status (active/paused/replied/bounced/unsubscribed/completed). New leads default to active and start receiving the campaign's first step.\n\n**Custom variables**: leads support arbitrary key/value pairs (e.g. `{firstName:'Jane', icebreaker:'...'}`) — referenced as `{{firstName}}` in the campaign emails. Lemlist's selling point is dynamic-personalization with image overlays driven by these variables.\n\n**Email accounts**: each campaign sends from a connected email account (Gmail, Outlook, custom SMTP). Configured in UI; the API doesn't expose connection management.\n\n**Pagination**: `limit` (default 100, max 100) + `offset`. Some endpoints return arrays without paging metadata — assume more if you get exactly `limit` items back.\n\n**Out of scope here**: campaign creation (the step-builder is UI-only), team analytics, hook editing (configured via Lemlist's UI), Lemlist Chrome extension actions.", + "region": "intl", + "category": "crm", + "icon": "lemlist", + "docsUrl": "https://developer.lemlist.com/", + "requiredEnvVars": ["LEMLIST_API_KEY"], + "connector": { + "name": "Lemlist v1", + "type": "REST", + "baseUrl": "https://api.lemlist.com/api", + "authType": "BASIC_AUTH", + "authConfig": { + "username": "", + "password": "{{LEMLIST_API_KEY}}" + } + }, + "tools": [ + { + "name": "lemlist_get_team", + "description": "Return the team the API key belongs to (id, name, plan, members). Health check + identifies the workspace.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/team" } + }, + { + "name": "lemlist_list_campaigns", + "description": "List campaigns. Each campaign has _id, name, labels, createdBy, status, scheduleTimezone, multichannel?.", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Max per page (default 100, max 100)." }, + "offset": { "type": "integer", "description": "Pagination offset." }, + "version": { "type": "string", "description": "Use 'v2' for the multichannel campaign schema." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/campaigns", + "queryParams": { "limit": "$limit", "offset": "$offset", "version": "$version" } + } + }, + { + "name": "lemlist_get_campaign_stats", + "description": "Get aggregated stats for a campaign: leads counts by status, emails sent/opened/clicked/replied/bounced, deliverability score.", + "parameters": { + "type": "object", + "properties": { + "campaignId": { "type": "string", "description": "Campaign ID." } + }, + "required": ["campaignId"] + }, + "endpointMapping": { "method": "GET", "path": "/campaigns/{campaignId}/stats" } + }, + { + "name": "lemlist_list_campaign_leads", + "description": "List leads (prospects) in a campaign. Each lead has _id, email, firstName, lastName, companyName, status, isPaused, customVariables.", + "parameters": { + "type": "object", + "properties": { + "campaignId": { "type": "string", "description": "Campaign ID." }, + "limit": { "type": "integer", "description": "Per page." }, + "offset": { "type": "integer", "description": "Pagination offset." }, + "version": { "type": "string", "description": "Use 'v2' for multichannel." } + }, + "required": ["campaignId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/campaigns/{campaignId}/leads", + "queryParams": { "limit": "$limit", "offset": "$offset", "version": "$version" } + } + }, + { + "name": "lemlist_add_lead_to_campaign", + "description": "Add a new prospect to a campaign. Required: campaignId + email. customVariables fill template placeholders. deduplicate=true skips if email already in the campaign.", + "parameters": { + "type": "object", + "properties": { + "campaignId": { "type": "string", "description": "Campaign ID." }, + "email": { "type": "string", "description": "Prospect email." }, + "firstName": { "type": "string", "description": "First name." }, + "lastName": { "type": "string", "description": "Last name." }, + "companyName": { "type": "string", "description": "Company name." }, + "picture": { "type": "string", "description": "Picture URL (used in image-personalization templates)." }, + "linkedinUrl": { "type": "string", "description": "LinkedIn URL." }, + "phone": { "type": "string", "description": "Phone for LinkedIn voice/SMS steps." }, + "customVariables": { "type": "object", "description": "Arbitrary {{key}} → value used in templates." }, + "deduplicate": { "type": "boolean", "description": "Skip if email already exists in the campaign. Default false." } + }, + "required": ["campaignId", "email"] + }, + "endpointMapping": { + "method": "POST", + "path": "/campaigns/{campaignId}/leads/{email}", + "bodyMapping": { + "firstName": "$firstName", + "lastName": "$lastName", + "companyName": "$companyName", + "picture": "$picture", + "linkedinUrl": "$linkedinUrl", + "phone": "$phone", + "customVariables": "$customVariables", + "deduplicate": "$deduplicate" + } + } + }, + { + "name": "lemlist_pause_lead", + "description": "Pause a lead in a campaign (stops further touches without unsubscribing them).", + "parameters": { + "type": "object", + "properties": { + "campaignId": { "type": "string", "description": "Campaign ID." }, + "email": { "type": "string", "description": "Lead email." } + }, + "required": ["campaignId", "email"] + }, + "endpointMapping": { + "method": "POST", + "path": "/campaigns/{campaignId}/leads/{email}/pause" + } + }, + { + "name": "lemlist_resume_lead", + "description": "Resume a previously-paused lead.", + "parameters": { + "type": "object", + "properties": { + "campaignId": { "type": "string", "description": "Campaign ID." }, + "email": { "type": "string", "description": "Lead email." } + }, + "required": ["campaignId", "email"] + }, + "endpointMapping": { + "method": "POST", + "path": "/campaigns/{campaignId}/leads/{email}/resume" + } + }, + { + "name": "lemlist_delete_lead_from_campaign", + "description": "Remove a lead from a campaign. They stop receiving touches but stay in your database.", + "parameters": { + "type": "object", + "properties": { + "campaignId": { "type": "string", "description": "Campaign ID." }, + "email": { "type": "string", "description": "Lead email." } + }, + "required": ["campaignId", "email"] + }, + "endpointMapping": { + "method": "DELETE", + "path": "/campaigns/{campaignId}/leads/{email}" + } + }, + { + "name": "lemlist_unsubscribe_lead", + "description": "Mark a lead as unsubscribed globally — they will no longer receive any Lemlist email from your team across all campaigns. Compliance-friendly.", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "Lead email." } + }, + "required": ["email"] + }, + "endpointMapping": { + "method": "POST", + "path": "/unsubscribes/{email}" + } + }, + { + "name": "lemlist_list_unsubscribes", + "description": "List all unsubscribed emails on the team (compliance reporting).", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Per page." }, + "offset": { "type": "integer", "description": "Pagination offset." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/unsubscribes", + "queryParams": { "limit": "$limit", "offset": "$offset" } + } + }, + { + "name": "lemlist_list_hooks", + "description": "List webhook subscriptions configured on the team (event delivery to your hosted endpoint).", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/hooks" } + } + ] +} diff --git a/packages/backend/src/adapters/intl/lemlist.live.spec.ts b/packages/backend/src/adapters/intl/lemlist.live.spec.ts new file mode 100644 index 0000000..53d5a25 --- /dev/null +++ b/packages/backend/src/adapters/intl/lemlist.live.spec.ts @@ -0,0 +1,12 @@ +import * as adapter from './lemlist.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: Record }; +}; +describe('lemlist adapter — static spec conformance', () => { + it('api.lemlist.com/api', () => expect(a.connector.baseUrl).toBe('https://api.lemlist.com/api')); + it('BASIC_AUTH with empty username and key as password (Lemlist convention)', () => { + expect(a.connector.authType).toBe('BASIC_AUTH'); + expect(a.connector.authConfig.username).toBe(''); + expect(a.connector.authConfig.password).toBe('{{LEMLIST_API_KEY}}'); + }); +}); diff --git a/packages/backend/src/adapters/intl/outreach.json b/packages/backend/src/adapters/intl/outreach.json new file mode 100644 index 0000000..e32ddf3 --- /dev/null +++ b/packages/backend/src/adapters/intl/outreach.json @@ -0,0 +1,243 @@ +{ + "slug": "outreach", + "name": "Outreach", + "description": "Drive Outreach (sales engagement platform) from any AI agent: prospects, accounts, sequences, sequence enrollment, tasks, users. 11 tools, OAuth2 Bearer auth, JSON:API spec.", + "instructions": "This connector uses the Outreach API v2 (developers.outreach.io).\n\n**Setup**:\n1. Outreach API access is OAuth2-only — no static API keys. Register an OAuth app at https://developers.outreach.io.\n2. Run the OAuth2 authorization-code flow to obtain an access_token (short-lived, ~7200 s) + refresh_token (long-lived). The access_token is what you pass to the connector.\n3. Set `OUTREACH_ACCESS_TOKEN`.\n4. Refresh logic: handle refresh-token rotation externally (the engine refreshes BEARER_TOKEN on 401 if `refreshToken` + `tokenUrl` are present in authConfig — but per-tenant OAuth2 setup is outside this connector's scope).\n\n**Authentication**: `Authorization: Bearer ${OUTREACH_ACCESS_TOKEN}`.\n\n**JSON:API spec**: every response wraps data in `{data:{id,type,attributes,relationships}, included?:[], meta, links}`. Use `?include=` (comma-separated) to side-load related resources. Use `?fields[TYPE]=...` for sparse fieldsets.\n\n**Outreach objects** (Outreach-specific naming):\n - **Prospect** = a person at an account (like Salesloft 'person').\n - **Account** = a company.\n - **Sequence** = multi-step outreach cadence.\n - **Sequence Step** = each touchpoint.\n - **Sequence State** = a prospect's enrollment in a sequence (active/paused/finished/bounced).\n - **Mailbox** = the email account used to send sequence emails.\n\n**Sequence enrollment**: POST `/sequenceStates` with relationships {prospect, sequence, mailbox}. The mailbox must belong to the API token's user OR a delegated user.\n\n**Filtering**: `filter[attribute]=value` query syntax. Multiple filters AND together. Example: `?filter[bounced]=true&filter[sequenceState.id]=N`.\n\n**Pagination**: `page[size]` (default 50, max 1000) + `page[after]` cursor.\n\n**Rate limits**: 10,000 req/hour per token (Enterprise). On 429 honor `Retry-After`.\n\n**Out of scope here**: sequence template editing, calling, conversational intelligence, opportunity sync (use the CRM directly), task templates.", + "region": "intl", + "category": "crm", + "icon": "outreach", + "docsUrl": "https://developers.outreach.io/api/reference/", + "requiredEnvVars": ["OUTREACH_ACCESS_TOKEN"], + "connector": { + "name": "Outreach v2", + "type": "REST", + "baseUrl": "https://api.outreach.io/api/v2", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{OUTREACH_ACCESS_TOKEN}}" + } + }, + "tools": [ + { + "name": "outreach_list_prospects", + "description": "List prospects with filters. Supports filter[emails], filter[accountId], filter[ownerId], filter[stage.id], filter[engagedAt][gte], etc.", + "parameters": { + "type": "object", + "properties": { + "filter_emails": { "type": "string", "description": "Comma-separated email exact-match." }, + "filter_account_id": { "type": "string", "description": "Account ID filter." }, + "filter_owner_id": { "type": "string", "description": "Owner user ID." }, + "filter_stage_id": { "type": "string", "description": "Stage ID." }, + "sort": { "type": "string", "description": "Sort attribute, prefix - for desc: -updatedAt." }, + "page_size": { "type": "integer", "description": "Per page (default 50, max 1000)." }, + "page_after": { "type": "string", "description": "Cursor for next page." }, + "include": { "type": "string", "description": "Side-load: account, owner, currentlyActiveSequenceStates, etc." }, + "fields_prospect": { "type": "string", "description": "Sparse fieldset for prospect attributes." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/prospects", + "queryParams": { + "filter[emails]": "$filter_emails", + "filter[accountId]": "$filter_account_id", + "filter[ownerId]": "$filter_owner_id", + "filter[stage.id]": "$filter_stage_id", + "sort": "$sort", + "page[size]": "$page_size", + "page[after]": "$page_after", + "include": "$include", + "fields[prospect]": "$fields_prospect" + } + } + }, + { + "name": "outreach_get_prospect", + "description": "Fetch a prospect by ID with full attributes and relationships.", + "parameters": { + "type": "object", + "properties": { + "prospectId": { "type": "string", "description": "Prospect ID." }, + "include": { "type": "string", "description": "Side-load related resources." } + }, + "required": ["prospectId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/prospects/{prospectId}", + "queryParams": { "include": "$include" } + } + }, + { + "name": "outreach_create_prospect", + "description": "Create a prospect. JSON:API envelope: {data: {type:'prospect', attributes:{emails:['a@b.com'], firstName, lastName, ...}, relationships:{account:{data:{type:'account',id:'N'}}, owner:{data:{type:'user',id:'M'}}}}}.", + "parameters": { + "type": "object", + "properties": { + "data": { + "type": "object", + "description": "JSON:API data envelope. {type:'prospect', attributes:{emails:['email1','email2'], firstName?, lastName?, title?, addressCity?, ...}, relationships?:{account, owner}}. Wrap inside {data:{...}}." + } + }, + "required": ["data"] + }, + "endpointMapping": { + "method": "POST", + "path": "/prospects", + "bodyMapping": { "data": "$data" } + } + }, + { + "name": "outreach_update_prospect", + "description": "Update a prospect. JSON:API PATCH envelope: {data: {type:'prospect', id:PROSPECT_ID, attributes:{...}}}.", + "parameters": { + "type": "object", + "properties": { + "prospectId": { "type": "string", "description": "Prospect ID." }, + "data": { + "type": "object", + "description": "{type:'prospect', id:PROSPECT_ID, attributes:{...changed fields...}}. Wrap inside {data:{...}}." + } + }, + "required": ["prospectId", "data"] + }, + "endpointMapping": { + "method": "PATCH", + "path": "/prospects/{prospectId}", + "bodyMapping": { "data": "$data" } + } + }, + { + "name": "outreach_list_accounts", + "description": "List accounts with filters. Each account has name, domain, industry, numberOfEmployees, websiteUrl, addressCity, custom*.", + "parameters": { + "type": "object", + "properties": { + "filter_name": { "type": "string", "description": "Name filter." }, + "filter_domain": { "type": "string", "description": "Domain filter." }, + "filter_owner_id": { "type": "string", "description": "Owner user ID." }, + "page_size": { "type": "integer", "description": "Per page." }, + "page_after": { "type": "string", "description": "Cursor." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/accounts", + "queryParams": { + "filter[name]": "$filter_name", + "filter[domain]": "$filter_domain", + "filter[ownerId]": "$filter_owner_id", + "page[size]": "$page_size", + "page[after]": "$page_after" + } + } + }, + { + "name": "outreach_list_sequences", + "description": "List sequences (cadences). Each has name, shareType (private/shared), enabled, numActiveProspects, numSteps.", + "parameters": { + "type": "object", + "properties": { + "filter_enabled": { "type": "boolean", "description": "Only enabled sequences." }, + "filter_share_type": { "type": "string", "description": "private, shared." }, + "page_size": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/sequences", + "queryParams": { + "filter[enabled]": "$filter_enabled", + "filter[shareType]": "$filter_share_type", + "page[size]": "$page_size" + } + } + }, + { + "name": "outreach_enroll_in_sequence", + "description": "Enroll a prospect in a sequence (create sequenceState). The mailbox must belong to a user with permission to send through it. JSON:API envelope.", + "parameters": { + "type": "object", + "properties": { + "data": { + "type": "object", + "description": "{type:'sequenceState', relationships:{prospect:{data:{type:'prospect',id:'N'}}, sequence:{data:{type:'sequence',id:'M'}}, mailbox:{data:{type:'mailbox',id:'K'}}}}. Wrap inside {data:{...}}." + } + }, + "required": ["data"] + }, + "endpointMapping": { + "method": "POST", + "path": "/sequenceStates", + "bodyMapping": { "data": "$data" } + } + }, + { + "name": "outreach_list_mailboxes", + "description": "List mailboxes (email accounts connected to Outreach for sending). Returns id, email, sendType (sequenceSend / replyFromSequence), userId.", + "parameters": { + "type": "object", + "properties": { + "filter_user_id": { "type": "string", "description": "Filter by owner user ID." }, + "page_size": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/mailboxes", + "queryParams": { + "filter[userId]": "$filter_user_id", + "page[size]": "$page_size" + } + } + }, + { + "name": "outreach_list_users", + "description": "List Outreach users. Returns id, name, email, role, locked, createdAt.", + "parameters": { + "type": "object", + "properties": { + "filter_email": { "type": "string", "description": "Exact email filter." }, + "page_size": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/users", + "queryParams": { + "filter[email]": "$filter_email", + "page[size]": "$page_size" + } + } + }, + { + "name": "outreach_list_tasks", + "description": "List tasks (manual steps in sequences that need agent action — calls, custom emails, LinkedIn DMs). Filter by state (pending/skipped/completed) and assignee.", + "parameters": { + "type": "object", + "properties": { + "filter_state": { "type": "string", "description": "pending, skipped, completed, scheduled, expired." }, + "filter_owner_id": { "type": "string", "description": "Assignee user ID." }, + "filter_prospect_id": { "type": "string", "description": "Prospect ID." }, + "page_size": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/tasks", + "queryParams": { + "filter[state]": "$filter_state", + "filter[ownerId]": "$filter_owner_id", + "filter[prospectId]": "$filter_prospect_id", + "page[size]": "$page_size" + } + } + }, + { + "name": "outreach_list_stages", + "description": "List prospect stages defined on the org (the funnel positions like Lead/MQL/SQL/Opportunity). Required to compose filter[stage.id] queries.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/stages" } + } + ] +} diff --git a/packages/backend/src/adapters/intl/outreach.live.spec.ts b/packages/backend/src/adapters/intl/outreach.live.spec.ts new file mode 100644 index 0000000..bc192af --- /dev/null +++ b/packages/backend/src/adapters/intl/outreach.live.spec.ts @@ -0,0 +1,13 @@ +import * as adapter from './outreach.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: Record }; + tools: Array<{ name: string; endpointMapping: { method: string; path: string } }>; +}; +describe('outreach adapter — static spec conformance', () => { + it('api.outreach.io/api/v2', () => expect(a.connector.baseUrl).toBe('https://api.outreach.io/api/v2')); + it('Bearer auth', () => expect(a.connector.authType).toBe('BEARER_TOKEN')); + it('update uses PATCH (JSON:API convention)', () => { + const t = a.tools.find((x) => x.name === 'outreach_update_prospect')!; + expect(t.endpointMapping.method).toBe('PATCH'); + }); +}); diff --git a/packages/backend/src/adapters/intl/salesloft.json b/packages/backend/src/adapters/intl/salesloft.json new file mode 100644 index 0000000..f4ca752 --- /dev/null +++ b/packages/backend/src/adapters/intl/salesloft.json @@ -0,0 +1,283 @@ +{ + "slug": "salesloft", + "name": "Salesloft", + "description": "Drive Salesloft (sales engagement platform) from any AI agent: people, accounts, cadences, sequence enrollment, activities, custom fields. 11 tools, OAuth2 Bearer auth.", + "instructions": "This connector uses the Salesloft v2 REST API (developers.salesloft.com).\n\n**Setup**:\n1. Salesloft API access requires OAuth2 — Personal API tokens are NOT exposed in the standard UI.\n2. Sign in to Salesloft → **Settings → Your API Access** (if visible) — if not, ask an admin to generate an OAuth token for you OR register an OAuth app.\n3. Obtain an access token (typically valid 30 minutes for OAuth flow; longer for refresh-token rotation).\n4. Set `SALESLOFT_ACCESS_TOKEN`.\n\n**Authentication**: `Authorization: Bearer ${SALESLOFT_ACCESS_TOKEN}`. The token expires (~30 min) — if you see 401s, refresh via Salesloft's OAuth refresh-token endpoint (out of scope here — handle via your OAuth client).\n\n**Salesloft objects**:\n - **People** = individual contacts (sales prospects). Have email + linked account.\n - **Accounts** = companies.\n - **Cadences** = multi-step outreach sequences (Salesloft's term for what Outreach calls 'sequences').\n - **Steps** = each touchpoint in a cadence (email/call/LinkedIn/task).\n - **Cadence Memberships** = enrollments of a person into a cadence.\n\n**Adding people to a cadence**: POST /cadence_memberships with person_id + cadence_id. Required: a user_id (who 'owns' this enrollment).\n\n**Pagination**: `per_page` (default 25, max 100) + `page` (1-based) on most endpoints.\n\n**Rate limits**: 600 req/min per organization. Per-endpoint subset limits exist (e.g. enrichment is slower). On 429 honor `Retry-After`.\n\n**Out of scope here**: cadence template editing, conversation intelligence, call dispositions, deal/opportunity sync (Salesloft has a CRM-sync layer with HubSpot/Salesforce; manage through CRM directly).", + "region": "intl", + "category": "crm", + "icon": "salesloft", + "docsUrl": "https://developers.salesloft.com/", + "requiredEnvVars": ["SALESLOFT_ACCESS_TOKEN"], + "connector": { + "name": "Salesloft v2", + "type": "REST", + "baseUrl": "https://api.salesloft.com/v2", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{SALESLOFT_ACCESS_TOKEN}}" + } + }, + "tools": [ + { + "name": "salesloft_me", + "description": "Return the authenticated user info: id, name, email, guid, team_id, role. Health check + whoami.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/me.json" } + }, + { + "name": "salesloft_list_people", + "description": "List people (prospects). Supports filtering by email, account_id, owned_by_id, custom_field_ids, last_contacted_date.", + "parameters": { + "type": "object", + "properties": { + "ids": { "type": "array", "description": "Filter to specific person IDs." }, + "email_addresses": { "type": "array", "description": "Filter by email addresses." }, + "account_id": { "type": "array", "description": "Filter by account ID(s)." }, + "owned_by_id": { "type": "array", "description": "Filter by owner user ID(s)." }, + "person_stage_id": { "type": "array", "description": "Filter by stage ID(s)." }, + "updated_at": { "type": "object", "description": "{gt?, lt?} ISO 8601 datetime range." }, + "page": { "type": "integer", "description": "1-based page." }, + "per_page": { "type": "integer", "description": "Per page (default 25, max 100)." }, + "include_paging_counts": { "type": "boolean", "description": "Include total count in metadata (expensive)." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/people.json", + "queryParams": { + "ids[]": "$ids", + "email_addresses[]": "$email_addresses", + "account_id[]": "$account_id", + "owned_by_id[]": "$owned_by_id", + "person_stage_id[]": "$person_stage_id", + "updated_at": "$updated_at", + "page": "$page", + "per_page": "$per_page", + "include_paging_counts": "$include_paging_counts" + } + } + }, + { + "name": "salesloft_get_person", + "description": "Fetch a person by ID. Full details including emails, phones, account, owner, custom_fields, last_contacted_at, last_replied_at.", + "parameters": { + "type": "object", + "properties": { + "personId": { "type": "integer", "description": "Person ID." } + }, + "required": ["personId"] + }, + "endpointMapping": { "method": "GET", "path": "/people/{personId}.json" } + }, + { + "name": "salesloft_create_person", + "description": "Create a person. Required: email_address (or first_name + last_name + account_id).", + "parameters": { + "type": "object", + "properties": { + "email_address": { "type": "string", "description": "Primary email." }, + "first_name": { "type": "string", "description": "First name." }, + "last_name": { "type": "string", "description": "Last name." }, + "title": { "type": "string", "description": "Job title." }, + "phone": { "type": "string", "description": "Primary phone." }, + "mobile_phone": { "type": "string", "description": "Mobile phone." }, + "linkedin_url": { "type": "string", "description": "LinkedIn URL." }, + "account_id": { "type": "integer", "description": "Linked account ID." }, + "owner_id": { "type": "integer", "description": "User ID of owner." }, + "person_stage_id": { "type": "integer", "description": "Stage ID." }, + "tags": { "type": "array", "description": "Tag name strings." }, + "custom_fields": { "type": "object", "description": "Custom field map (use salesloft_list_custom_fields to discover keys)." } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/people.json", + "bodyMapping": { + "email_address": "$email_address", + "first_name": "$first_name", + "last_name": "$last_name", + "title": "$title", + "phone": "$phone", + "mobile_phone": "$mobile_phone", + "linkedin_url": "$linkedin_url", + "account_id": "$account_id", + "owner_id": "$owner_id", + "person_stage_id": "$person_stage_id", + "tags": "$tags", + "custom_fields": "$custom_fields" + } + } + }, + { + "name": "salesloft_update_person", + "description": "Update a person. Partial.", + "parameters": { + "type": "object", + "properties": { + "personId": { "type": "integer", "description": "Person ID." }, + "email_address": { "type": "string", "description": "New email." }, + "first_name": { "type": "string", "description": "New first name." }, + "last_name": { "type": "string", "description": "New last name." }, + "title": { "type": "string", "description": "New title." }, + "phone": { "type": "string", "description": "New phone." }, + "owner_id": { "type": "integer", "description": "Reassign." }, + "person_stage_id": { "type": "integer", "description": "Change stage." }, + "tags": { "type": "array", "description": "Replace tags." } + }, + "required": ["personId"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/people/{personId}.json", + "bodyMapping": { + "email_address": "$email_address", + "first_name": "$first_name", + "last_name": "$last_name", + "title": "$title", + "phone": "$phone", + "owner_id": "$owner_id", + "person_stage_id": "$person_stage_id", + "tags": "$tags" + } + } + }, + { + "name": "salesloft_list_accounts", + "description": "List accounts (companies). Filter by name, domain, account_tier_id, owner_id.", + "parameters": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Substring match on name." }, + "domain": { "type": "string", "description": "Exact domain." }, + "owned_by_id": { "type": "array", "description": "Owner user IDs." }, + "per_page": { "type": "integer", "description": "Per page." }, + "page": { "type": "integer", "description": "Page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/accounts.json", + "queryParams": { + "name": "$name", + "domain": "$domain", + "owned_by_id[]": "$owned_by_id", + "per_page": "$per_page", + "page": "$page" + } + } + }, + { + "name": "salesloft_list_cadences", + "description": "List cadences (multi-step sequences). Each has id, name, current_state, draft, type, shared, team_cadence.", + "parameters": { + "type": "object", + "properties": { + "owned_by_id": { "type": "array", "description": "Owner filter." }, + "shared": { "type": "boolean", "description": "Only shared cadences." }, + "per_page": { "type": "integer", "description": "Per page." }, + "page": { "type": "integer", "description": "Page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/cadences.json", + "queryParams": { + "owned_by_id[]": "$owned_by_id", + "shared": "$shared", + "per_page": "$per_page", + "page": "$page" + } + } + }, + { + "name": "salesloft_enroll_person_in_cadence", + "description": "Add a person to a cadence (create a cadence_membership). Required: person_id + cadence_id + user_id (the user who 'sends' from this enrollment).", + "parameters": { + "type": "object", + "properties": { + "person_id": { "type": "integer", "description": "Person to enroll." }, + "cadence_id": { "type": "integer", "description": "Cadence to enroll into." }, + "user_id": { "type": "integer", "description": "User who owns this enrollment (their email is the From address for cadence emails)." } + }, + "required": ["person_id", "cadence_id", "user_id"] + }, + "endpointMapping": { + "method": "POST", + "path": "/cadence_memberships.json", + "bodyMapping": { + "person_id": "$person_id", + "cadence_id": "$cadence_id", + "user_id": "$user_id" + } + } + }, + { + "name": "salesloft_list_users", + "description": "List Salesloft users in your team. Returns id, name, email, role, time_zone, locale.", + "parameters": { + "type": "object", + "properties": { + "ids": { "type": "array", "description": "Filter to specific user IDs." }, + "guid": { "type": "string", "description": "Filter by guid." }, + "email": { "type": "string", "description": "Email filter." }, + "active": { "type": "boolean", "description": "Active flag filter." }, + "per_page": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/users.json", + "queryParams": { + "ids[]": "$ids", + "guid": "$guid", + "email": "$email", + "active": "$active", + "per_page": "$per_page" + } + } + }, + { + "name": "salesloft_create_activity_call", + "description": "Log a call activity on a person. Use for after-call dispositions.", + "parameters": { + "type": "object", + "properties": { + "person_id": { "type": "integer", "description": "Person ID." }, + "to": { "type": "string", "description": "Phone number called (E.164)." }, + "duration": { "type": "integer", "description": "Call duration in seconds." }, + "sentiment": { "type": "string", "description": "Call sentiment label (configured in Salesloft)." }, + "disposition": { "type": "string", "description": "Call disposition label." }, + "notes": { "type": "string", "description": "Free-text notes." } + }, + "required": ["person_id"] + }, + "endpointMapping": { + "method": "POST", + "path": "/activities/calls.json", + "bodyMapping": { + "person_id": "$person_id", + "to": "$to", + "duration": "$duration", + "sentiment": "$sentiment", + "disposition": "$disposition", + "notes": "$notes" + } + } + }, + { + "name": "salesloft_list_custom_fields", + "description": "List custom fields defined for people / accounts. Returns id, name, field_type, accessibility, value_type.", + "parameters": { + "type": "object", + "properties": { + "field_type": { "type": "string", "description": "person, account, opportunity." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/custom_fields.json", + "queryParams": { "field_type": "$field_type" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/salesloft.live.spec.ts b/packages/backend/src/adapters/intl/salesloft.live.spec.ts new file mode 100644 index 0000000..f6b15d6 --- /dev/null +++ b/packages/backend/src/adapters/intl/salesloft.live.spec.ts @@ -0,0 +1,12 @@ +import * as adapter from './salesloft.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: Record }; + tools: Array<{ name: string; endpointMapping: { method: string; path: string } }>; +}; +describe('salesloft adapter — static spec conformance', () => { + it('api.salesloft.com/v2', () => expect(a.connector.baseUrl).toBe('https://api.salesloft.com/v2')); + it('Bearer auth', () => expect(a.connector.authType).toBe('BEARER_TOKEN')); + it('all paths end with .json (Salesloft v2 convention)', () => { + for (const t of a.tools) expect(t.endpointMapping.path.endsWith('.json')).toBe(true); + }); +}); diff --git a/packages/backend/src/connectors/engines/rest.engine.ts b/packages/backend/src/connectors/engines/rest.engine.ts index 28f6c72..a3d49ed 100644 --- a/packages/backend/src/connectors/engines/rest.engine.ts +++ b/packages/backend/src/connectors/engines/rest.engine.ts @@ -199,14 +199,25 @@ export class RestEngine { if (!config.authConfig) return; switch (config.authType) { - case 'API_KEY': + case 'API_KEY': { axiosConfig.headers = { ...axiosConfig.headers, [String(config.authConfig.headerName || 'X-API-Key')]: String( config.authConfig.apiKey, ), }; + // Some vendors require additional fixed headers alongside the key + // (e.g. Copper sends X-PW-AccessToken + X-PW-Application + X-PW-UserEmail). + const extra = config.authConfig.extraHeaders as + | Record + | undefined; + if (extra && typeof extra === 'object') { + for (const [k, v] of Object.entries(extra)) { + axiosConfig.headers[k] = String(v); + } + } break; + } case 'BEARER_TOKEN': axiosConfig.headers = { ...axiosConfig.headers, From fc9b22a6bc56a0df444af07a1c2467cca0b7bc25 Mon Sep 17 00:00:00 2001 From: Matteo Date: Wed, 20 May 2026 12:37:21 +0200 Subject: [PATCH 07/19] connectors: add ClickUp, Trello, Todoist, Basecamp, Coda MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 4 — project / task management batch. Five connectors covering the full spectrum from personal todos (Todoist) to enterprise project management (Basecamp, ClickUp). - ClickUp v2: 16 tools — workspace/space/folder/list hierarchy navigation, tasks CRUD with custom_task_ids support, comments, custom fields per list, team members. Raw token in Authorization header (no Bearer prefix — ClickUp-specific). - Trello v1: 13 tools — boards, lists, cards CRUD with move/archive, comments, URL attachments, board labels, universal search. QUERY_AUTH with key+token (Trello's classic 2-value scheme). - Todoist API v1: 12 tools — projects, tasks with natural-language due dates, comments, labels. Switched to the modern /api/v1 base (rest/v2 is deprecated as of 2025 — vendor returned 410 Gone). - Basecamp 4: 12 tools — projects with dock discovery, todolists and todos with complete/uncomplete via POST/DELETE on completion, message board posts, comments on any recording, people. Vendor REQUIRES User-Agent header on every request — pinned per-tool. - Coda v1: 13 tools — docs, tables, columns, rows with formula-based filtering and upsert via keyColumns, async mutation status check, named-formula evaluation. Catalog: 65 adapters. All 5 smoke-tested. --- packages/backend/src/adapters/catalog.ts | 10 + .../backend/src/adapters/intl/basecamp.json | 242 +++++++++++++ .../src/adapters/intl/basecamp.live.spec.ts | 15 + .../backend/src/adapters/intl/clickup.json | 321 ++++++++++++++++++ .../src/adapters/intl/clickup.live.spec.ts | 12 + packages/backend/src/adapters/intl/coda.json | 288 ++++++++++++++++ .../src/adapters/intl/coda.live.spec.ts | 8 + .../backend/src/adapters/intl/todoist.json | 254 ++++++++++++++ .../src/adapters/intl/todoist.live.spec.ts | 11 + .../backend/src/adapters/intl/trello.json | 301 ++++++++++++++++ .../src/adapters/intl/trello.live.spec.ts | 12 + 11 files changed, 1474 insertions(+) create mode 100644 packages/backend/src/adapters/intl/basecamp.json create mode 100644 packages/backend/src/adapters/intl/basecamp.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/clickup.json create mode 100644 packages/backend/src/adapters/intl/clickup.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/coda.json create mode 100644 packages/backend/src/adapters/intl/coda.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/todoist.json create mode 100644 packages/backend/src/adapters/intl/todoist.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/trello.json create mode 100644 packages/backend/src/adapters/intl/trello.live.spec.ts diff --git a/packages/backend/src/adapters/catalog.ts b/packages/backend/src/adapters/catalog.ts index 4c80c2f..170c949 100644 --- a/packages/backend/src/adapters/catalog.ts +++ b/packages/backend/src/adapters/catalog.ts @@ -33,9 +33,12 @@ import * as companiesHouse from './gb/companies-house.json'; import * as wise from './gb/wise.json'; import * as activecampaign from './intl/activecampaign.json'; import * as apollo from './intl/apollo.json'; +import * as basecamp from './intl/basecamp.json'; import * as brevo from './intl/brevo.json'; import * as calendly from './intl/calendly.json'; +import * as clickup from './intl/clickup.json'; import * as close from './intl/close.json'; +import * as coda from './intl/coda.json'; import * as convertkit from './intl/convertkit.json'; import * as copper from './intl/copper.json'; import * as discordBot from './intl/discord-bot.json'; @@ -50,6 +53,8 @@ import * as salesloft from './intl/salesloft.json'; import * as sendgrid from './intl/sendgrid.json'; import * as sorare from './intl/sorare.json'; import * as telegramBot from './intl/telegram-bot.json'; +import * as todoist from './intl/todoist.json'; +import * as trello from './intl/trello.json'; import * as typeform from './intl/typeform.json'; import * as whatsappBusiness from './intl/whatsapp-business.json'; import * as woocommerce from './intl/woocommerce.json'; @@ -165,9 +170,12 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ wise as unknown as AdapterDefinition, activecampaign as unknown as AdapterDefinition, apollo as unknown as AdapterDefinition, + basecamp as unknown as AdapterDefinition, brevo as unknown as AdapterDefinition, calendly as unknown as AdapterDefinition, + clickup as unknown as AdapterDefinition, close as unknown as AdapterDefinition, + coda as unknown as AdapterDefinition, convertkit as unknown as AdapterDefinition, copper as unknown as AdapterDefinition, discordBot as unknown as AdapterDefinition, @@ -182,6 +190,8 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ sendgrid as unknown as AdapterDefinition, sorare as unknown as AdapterDefinition, telegramBot as unknown as AdapterDefinition, + todoist as unknown as AdapterDefinition, + trello as unknown as AdapterDefinition, typeform as unknown as AdapterDefinition, whatsappBusiness as unknown as AdapterDefinition, woocommerce as unknown as AdapterDefinition, diff --git a/packages/backend/src/adapters/intl/basecamp.json b/packages/backend/src/adapters/intl/basecamp.json new file mode 100644 index 0000000..0dfe3ff --- /dev/null +++ b/packages/backend/src/adapters/intl/basecamp.json @@ -0,0 +1,242 @@ +{ + "slug": "basecamp", + "name": "Basecamp", + "description": "Drive Basecamp 4 (project & team collaboration) from any AI agent: projects, todo lists, todos, messages, comments, people. 12 tools, OAuth2 Bearer auth.", + "instructions": "This connector uses the Basecamp 4 API (37signals.com — github.com/basecamp/bc3-api).\n\n**Setup**:\n1. Register an integration at https://launchpad.37signals.com/integrations.\n2. Implement OAuth2 authorization-code flow with `https://launchpad.37signals.com/authorization` (response includes user's account_id).\n3. Set `BASECAMP_ACCESS_TOKEN` to the obtained access token.\n4. Set `BASECAMP_ACCOUNT_ID` to your Basecamp account ID (a 6-9 digit number — visible in any Basecamp URL or returned by the OAuth identity endpoint).\n\n**Authentication**: `Authorization: Bearer ${BASECAMP_ACCESS_TOKEN}`.\n\n**Account-scoped base URL**: every Basecamp account has its own subdomain. The API URL is `https://3.basecampapi.com/${BASECAMP_ACCOUNT_ID}/...`. The adapter substitutes the account ID via env var template.\n\n**User-Agent header**: Basecamp REQUIRES a meaningful User-Agent header on every request (their support staff need it to identify integrations). The adapter sets `User-Agent: AnythingMCP (https://anythingmcp.com)` automatically.\n\n**Resource model**:\n - **Project** (called 'Basecamp' in the UI) — the top-level container.\n - **Dock** — array of tools enabled on the project (todoset, message_board, schedule, vault, chat, etc.) each with its own URL.\n - **Todoset** → multiple **Todolists** → multiple **Todos**.\n - **Message Board** → multiple **Messages**.\n - **Comments** can be attached to most recordings (todos, messages, etc.).\n\n**Recordings**: Basecamp's underlying primitive. A todo, message, comment, etc. is all a 'recording' with an `id` and a `type`. Many endpoints accept any recording_id and dispatch by type.\n\n**Person picker**: assignees are people IDs. List them via `basecamp_get_people` (account-wide) or per-project.\n\n**Status of completed todos**: completed=true sends a Boolean — not a separate endpoint. To 'reopen' a todo, send completed=false on the same todo.\n\n**Pagination**: 50 per page typical, Link header gives `next` URL. Some endpoints return all in one shot.\n\n**Rate limits**: 50 req per 10 sec per account. On 429 honor Retry-After.\n\n**Out of scope here**: Hill Charts, automatic check-ins, schedule entries, vault (file upload), Pings (DMs), Chat (Campfire) — all addressable via the API but each have idiosyncratic URLs returned by the Project dock; out of scope for this MVP connector.", + "region": "intl", + "category": "project-management", + "icon": "basecamp", + "docsUrl": "https://github.com/basecamp/bc3-api", + "requiredEnvVars": ["BASECAMP_ACCESS_TOKEN", "BASECAMP_ACCOUNT_ID"], + "connector": { + "name": "Basecamp 4", + "type": "REST", + "baseUrl": "https://3.basecampapi.com/{{BASECAMP_ACCOUNT_ID}}", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{BASECAMP_ACCESS_TOKEN}}" + } + }, + "tools": [ + { + "name": "basecamp_get_my_profile", + "description": "Return the authenticated user's profile (id, name, email_address, time_zone, avatar_url, can_manage_projects, etc.).", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { + "method": "GET", + "path": "/my/profile.json", + "headers": { "User-Agent": "AnythingMCP (https://anythingmcp.com)" } + } + }, + { + "name": "basecamp_list_projects", + "description": "List active projects (Basecamps) the user has access to. Each project has id, name, description, dock[], purpose ('topic' or 'team').", + "parameters": { + "type": "object", + "properties": { + "status": { "type": "string", "description": "active or archived or trashed." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/projects.json", + "queryParams": { "status": "$status" }, + "headers": { "User-Agent": "AnythingMCP (https://anythingmcp.com)" } + } + }, + { + "name": "basecamp_get_project", + "description": "Fetch a single project with its dock — the array of tools (todoset, message_board, etc.) each with an `id` and `url`. You'll grab the todoset_id and message_board_id from here.", + "parameters": { + "type": "object", + "properties": { + "projectId": { "type": "string", "description": "Project ID." } + }, + "required": ["projectId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/projects/{projectId}.json", + "headers": { "User-Agent": "AnythingMCP (https://anythingmcp.com)" } + } + }, + { + "name": "basecamp_list_todolists", + "description": "List todolists within a project's todoset. Get the todoset_id from basecamp_get_project's dock[type=todoset].id.", + "parameters": { + "type": "object", + "properties": { + "projectId": { "type": "string", "description": "Project ID." }, + "todosetId": { "type": "string", "description": "Todoset ID (from project dock)." } + }, + "required": ["projectId", "todosetId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/buckets/{projectId}/todosets/{todosetId}/todolists.json", + "headers": { "User-Agent": "AnythingMCP (https://anythingmcp.com)" } + } + }, + { + "name": "basecamp_list_todos", + "description": "List todos in a todolist. Pass status=completed to see done items, archived to see archived.", + "parameters": { + "type": "object", + "properties": { + "projectId": { "type": "string", "description": "Project ID." }, + "todolistId": { "type": "string", "description": "Todolist ID." }, + "status": { "type": "string", "description": "completed or archived. Omit for active." } + }, + "required": ["projectId", "todolistId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/buckets/{projectId}/todolists/{todolistId}/todos.json", + "queryParams": { "status": "$status" }, + "headers": { "User-Agent": "AnythingMCP (https://anythingmcp.com)" } + } + }, + { + "name": "basecamp_create_todo", + "description": "Create a todo in a todolist. Required: content. Assignees + notify are arrays of person IDs.", + "parameters": { + "type": "object", + "properties": { + "projectId": { "type": "string", "description": "Project ID." }, + "todolistId": { "type": "string", "description": "Todolist ID." }, + "content": { "type": "string", "description": "Todo content (title)." }, + "description": { "type": "string", "description": "Longer description (HTML or rich-text)." }, + "assignee_ids": { "type": "array", "description": "Array of person IDs to assign." }, + "completion_subscriber_ids": { "type": "array", "description": "People to notify on completion." }, + "notify": { "type": "boolean", "description": "If true, notify assignees on create." }, + "due_on": { "type": "string", "description": "Due date YYYY-MM-DD." }, + "starts_on": { "type": "string", "description": "Start date YYYY-MM-DD." } + }, + "required": ["projectId", "todolistId", "content"] + }, + "endpointMapping": { + "method": "POST", + "path": "/buckets/{projectId}/todolists/{todolistId}/todos.json", + "bodyMapping": { + "content": "$content", + "description": "$description", + "assignee_ids": "$assignee_ids", + "completion_subscriber_ids": "$completion_subscriber_ids", + "notify": "$notify", + "due_on": "$due_on", + "starts_on": "$starts_on" + }, + "headers": { "User-Agent": "AnythingMCP (https://anythingmcp.com)" } + } + }, + { + "name": "basecamp_complete_todo", + "description": "Mark a todo as completed. Use basecamp_uncomplete_todo to reopen.", + "parameters": { + "type": "object", + "properties": { + "projectId": { "type": "string", "description": "Project ID." }, + "todoId": { "type": "string", "description": "Todo ID." } + }, + "required": ["projectId", "todoId"] + }, + "endpointMapping": { + "method": "POST", + "path": "/buckets/{projectId}/todos/{todoId}/completion.json", + "headers": { "User-Agent": "AnythingMCP (https://anythingmcp.com)" } + } + }, + { + "name": "basecamp_uncomplete_todo", + "description": "Reopen a completed todo.", + "parameters": { + "type": "object", + "properties": { + "projectId": { "type": "string", "description": "Project ID." }, + "todoId": { "type": "string", "description": "Todo ID." } + }, + "required": ["projectId", "todoId"] + }, + "endpointMapping": { + "method": "DELETE", + "path": "/buckets/{projectId}/todos/{todoId}/completion.json", + "headers": { "User-Agent": "AnythingMCP (https://anythingmcp.com)" } + } + }, + { + "name": "basecamp_list_messages", + "description": "List messages on the project's message board. Get the message_board_id from basecamp_get_project's dock[type=message_board].id.", + "parameters": { + "type": "object", + "properties": { + "projectId": { "type": "string", "description": "Project ID." }, + "messageBoardId": { "type": "string", "description": "Message Board ID." } + }, + "required": ["projectId", "messageBoardId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/buckets/{projectId}/message_boards/{messageBoardId}/messages.json", + "headers": { "User-Agent": "AnythingMCP (https://anythingmcp.com)" } + } + }, + { + "name": "basecamp_post_message", + "description": "Post a new message on the project's message board.", + "parameters": { + "type": "object", + "properties": { + "projectId": { "type": "string", "description": "Project ID." }, + "messageBoardId": { "type": "string", "description": "Message Board ID." }, + "subject": { "type": "string", "description": "Message subject (title)." }, + "content": { "type": "string", "description": "Message body (HTML/rich-text)." }, + "status": { "type": "string", "description": "active (default) or draft." }, + "category_id": { "type": "integer", "description": "Optional message category ID." }, + "subscriptions": { "type": "array", "description": "Array of person IDs to subscribe to replies." } + }, + "required": ["projectId", "messageBoardId", "subject", "content"] + }, + "endpointMapping": { + "method": "POST", + "path": "/buckets/{projectId}/message_boards/{messageBoardId}/messages.json", + "bodyMapping": { + "subject": "$subject", + "content": "$content", + "status": "$status", + "category_id": "$category_id", + "subscriptions": "$subscriptions" + }, + "headers": { "User-Agent": "AnythingMCP (https://anythingmcp.com)" } + } + }, + { + "name": "basecamp_add_comment", + "description": "Add a comment to a recording (todo, message, document, etc.). recordingId is the ID of whatever you're commenting on.", + "parameters": { + "type": "object", + "properties": { + "projectId": { "type": "string", "description": "Project ID." }, + "recordingId": { "type": "string", "description": "ID of the recording to comment on." }, + "content": { "type": "string", "description": "Comment body (HTML/rich-text)." } + }, + "required": ["projectId", "recordingId", "content"] + }, + "endpointMapping": { + "method": "POST", + "path": "/buckets/{projectId}/recordings/{recordingId}/comments.json", + "bodyMapping": { "content": "$content" }, + "headers": { "User-Agent": "AnythingMCP (https://anythingmcp.com)" } + } + }, + { + "name": "basecamp_get_people", + "description": "List all people in the Basecamp account. Returns id, name, email_address, time_zone, can_manage_projects, can_manage_people.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { + "method": "GET", + "path": "/people.json", + "headers": { "User-Agent": "AnythingMCP (https://anythingmcp.com)" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/basecamp.live.spec.ts b/packages/backend/src/adapters/intl/basecamp.live.spec.ts new file mode 100644 index 0000000..482ffa5 --- /dev/null +++ b/packages/backend/src/adapters/intl/basecamp.live.spec.ts @@ -0,0 +1,15 @@ +import * as adapter from './basecamp.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: Record }; + tools: Array<{ name: string; endpointMapping: { method: string; path: string; headers?: Record } }>; +}; +describe('basecamp adapter — static spec conformance', () => { + it('account-templated base URL', () => { + expect(a.connector.baseUrl).toBe('https://3.basecampapi.com/{{BASECAMP_ACCOUNT_ID}}'); + }); + it('every tool sets User-Agent header (Basecamp requirement)', () => { + for (const t of a.tools) { + expect(t.endpointMapping.headers?.['User-Agent']).toMatch(/AnythingMCP/); + } + }); +}); diff --git a/packages/backend/src/adapters/intl/clickup.json b/packages/backend/src/adapters/intl/clickup.json new file mode 100644 index 0000000..5a24dda --- /dev/null +++ b/packages/backend/src/adapters/intl/clickup.json @@ -0,0 +1,321 @@ +{ + "slug": "clickup", + "name": "ClickUp", + "description": "Drive ClickUp (project management) from any AI agent: spaces, folders, lists, tasks, comments, time tracking, custom fields, teams, members. 16 tools, API token auth.", + "instructions": "This connector uses the ClickUp API v2 (clickup.com/api).\n\n**Setup**:\n1. Sign in to ClickUp → top-right avatar → **Settings → Apps → API Token → Generate** (personal token).\n2. Copy the token (starts with `pk_`). Set `CLICKUP_API_TOKEN`.\n\n**Authentication**: header `Authorization: ${CLICKUP_API_TOKEN}` — no `Bearer ` or `Token ` prefix, just the raw key. The adapter sets this via API_KEY profile.\n\n**Hierarchy**: Workspace (=Team) → Space → Folder (optional) → List → Task → Subtask. Lists can also exist directly inside a Space without a Folder. Discover IDs top-down: `clickup_list_teams` (returns workspaces, oddly called 'teams' in the API) → `clickup_list_spaces(teamId)` → `clickup_list_folders(spaceId)` → `clickup_list_lists(folderId)` OR `clickup_list_folderless_lists(spaceId)`.\n\n**Task IDs**: every task has an `id` (e.g. `abc123`). The same task can also be addressed by `custom_id` (the human-readable like 'PROJ-42') if your workspace enabled custom task IDs — set `custom_task_ids=true` in calls that use custom IDs.\n\n**Statuses are per-list**: each list has its own status set ('Open', 'In Progress', 'Done'). To move a task, set `status` to a status name (case-insensitive) that exists on the task's current list.\n\n**Custom fields**: arrays of `{id, value}` on every task. Discover via `clickup_get_list_custom_fields(listId)`.\n\n**Assignees**: array of user IDs (NOT emails). Get user IDs from `clickup_list_team_members(teamId)`.\n\n**Date fields**: Unix timestamp in MILLISECONDS (not seconds), e.g. `1717200000000`.\n\n**Pagination**: most list endpoints return up to 100 by default. Some support `page` (0-indexed) + `order_by` + `reverse`.\n\n**Rate limits**: 100 req/min for Free Forever, scales up with plan. On 429 honor `X-RateLimit-Reset` (Unix timestamp).\n\n**Out of scope here**: webhooks, automations, dependencies, goals/targets, attachments upload (use external URL), chat, dashboards, sprints.", + "region": "intl", + "category": "project-management", + "icon": "clickup", + "docsUrl": "https://clickup.com/api", + "requiredEnvVars": ["CLICKUP_API_TOKEN"], + "connector": { + "name": "ClickUp v2", + "type": "REST", + "baseUrl": "https://api.clickup.com/api/v2", + "authType": "API_KEY", + "authConfig": { + "headerName": "Authorization", + "apiKey": "{{CLICKUP_API_TOKEN}}" + } + }, + "tools": [ + { + "name": "clickup_get_authorized_user", + "description": "Return the authenticated user: id, username, email, color, profilePicture, initials. Health check + whoami.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/user" } + }, + { + "name": "clickup_list_teams", + "description": "List workspaces (called 'teams' in the API — confusingly different from per-workspace 'teams' which are user groups). Each returns id, name, color, avatar, members[].", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/team" } + }, + { + "name": "clickup_list_spaces", + "description": "List spaces in a workspace. Each space has id, name, private flag, statuses[], features (multiple_assignees, due_dates, custom_fields, etc.).", + "parameters": { + "type": "object", + "properties": { + "teamId": { "type": "string", "description": "Workspace ID." }, + "archived": { "type": "boolean", "description": "Include archived. Default false." } + }, + "required": ["teamId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/team/{teamId}/space", + "queryParams": { "archived": "$archived" } + } + }, + { + "name": "clickup_list_folders", + "description": "List folders in a space. Each folder has id, name, override_statuses flag, hidden flag, lists[].", + "parameters": { + "type": "object", + "properties": { + "spaceId": { "type": "string", "description": "Space ID." }, + "archived": { "type": "boolean", "description": "Include archived." } + }, + "required": ["spaceId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/space/{spaceId}/folder", + "queryParams": { "archived": "$archived" } + } + }, + { + "name": "clickup_list_lists", + "description": "List lists in a folder.", + "parameters": { + "type": "object", + "properties": { + "folderId": { "type": "string", "description": "Folder ID." }, + "archived": { "type": "boolean", "description": "Include archived." } + }, + "required": ["folderId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/folder/{folderId}/list", + "queryParams": { "archived": "$archived" } + } + }, + { + "name": "clickup_list_folderless_lists", + "description": "List lists that live directly in a space (no parent folder).", + "parameters": { + "type": "object", + "properties": { + "spaceId": { "type": "string", "description": "Space ID." }, + "archived": { "type": "boolean", "description": "Include archived." } + }, + "required": ["spaceId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/space/{spaceId}/list", + "queryParams": { "archived": "$archived" } + } + }, + { + "name": "clickup_list_tasks", + "description": "List tasks in a list with filtering. Supports filter by assignees, statuses, due_date_gt/lt, custom_fields, tags. Cursor pagination via page (0-indexed).", + "parameters": { + "type": "object", + "properties": { + "listId": { "type": "string", "description": "List ID." }, + "archived": { "type": "boolean", "description": "Include archived." }, + "page": { "type": "integer", "description": "0-indexed page." }, + "order_by": { "type": "string", "description": "id, created, updated, due_date." }, + "reverse": { "type": "boolean", "description": "Sort desc." }, + "subtasks": { "type": "boolean", "description": "Include subtasks." }, + "statuses": { "type": "array", "description": "Filter by status names." }, + "assignees": { "type": "array", "description": "Filter by assignee user IDs." }, + "tags": { "type": "array", "description": "Filter by tag names." }, + "due_date_gt": { "type": "integer", "description": "Due after this Unix ms." }, + "due_date_lt": { "type": "integer", "description": "Due before this Unix ms." }, + "include_closed": { "type": "boolean", "description": "Include closed tasks." } + }, + "required": ["listId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/list/{listId}/task", + "queryParams": { + "archived": "$archived", + "page": "$page", + "order_by": "$order_by", + "reverse": "$reverse", + "subtasks": "$subtasks", + "statuses[]": "$statuses", + "assignees[]": "$assignees", + "tags[]": "$tags", + "due_date_gt": "$due_date_gt", + "due_date_lt": "$due_date_lt", + "include_closed": "$include_closed" + } + } + }, + { + "name": "clickup_get_task", + "description": "Fetch a single task by ID with all fields including custom_fields, checklists, comments_count, dependencies, watchers.", + "parameters": { + "type": "object", + "properties": { + "taskId": { "type": "string", "description": "Task ID (or custom_id if custom_task_ids=true)." }, + "custom_task_ids": { "type": "boolean", "description": "If true, taskId is interpreted as a custom_id (like 'PROJ-42')." }, + "team_id": { "type": "string", "description": "Required when custom_task_ids=true." }, + "include_subtasks": { "type": "boolean", "description": "Include subtasks in response." } + }, + "required": ["taskId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/task/{taskId}", + "queryParams": { + "custom_task_ids": "$custom_task_ids", + "team_id": "$team_id", + "include_subtasks": "$include_subtasks" + } + } + }, + { + "name": "clickup_create_task", + "description": "Create a task in a list. Required: name. Date fields are Unix ms. Assignees are user IDs.", + "parameters": { + "type": "object", + "properties": { + "listId": { "type": "string", "description": "List ID." }, + "name": { "type": "string", "description": "Task title." }, + "description": { "type": "string", "description": "Markdown description." }, + "markdown_description": { "type": "string", "description": "Explicit Markdown description (ClickUp prefers this over `description`)." }, + "assignees": { "type": "array", "description": "Array of user IDs." }, + "tags": { "type": "array", "description": "Tag name strings." }, + "status": { "type": "string", "description": "Status name (case-insensitive). Must exist on the list." }, + "priority": { "type": "integer", "description": "1=Urgent, 2=High, 3=Normal, 4=Low." }, + "due_date": { "type": "integer", "description": "Due date Unix ms." }, + "due_date_time": { "type": "boolean", "description": "If true, due_date includes a time component." }, + "start_date": { "type": "integer", "description": "Start date Unix ms." }, + "time_estimate": { "type": "integer", "description": "Time estimate in ms." }, + "parent": { "type": "string", "description": "Parent task ID — creates this as a subtask." }, + "custom_fields": { "type": "array", "description": "[{id, value}] custom fields." }, + "notify_all": { "type": "boolean", "description": "Notify assignees and watchers." } + }, + "required": ["listId", "name"] + }, + "endpointMapping": { + "method": "POST", + "path": "/list/{listId}/task", + "bodyMapping": { + "name": "$name", + "description": "$description", + "markdown_description": "$markdown_description", + "assignees": "$assignees", + "tags": "$tags", + "status": "$status", + "priority": "$priority", + "due_date": "$due_date", + "due_date_time": "$due_date_time", + "start_date": "$start_date", + "time_estimate": "$time_estimate", + "parent": "$parent", + "custom_fields": "$custom_fields", + "notify_all": "$notify_all" + } + } + }, + { + "name": "clickup_update_task", + "description": "Update a task. Common: change status, reassign, change due_date.", + "parameters": { + "type": "object", + "properties": { + "taskId": { "type": "string", "description": "Task ID." }, + "name": { "type": "string", "description": "New name." }, + "description": { "type": "string", "description": "New description." }, + "status": { "type": "string", "description": "New status (must exist on list)." }, + "priority": { "type": "integer", "description": "1-4." }, + "due_date": { "type": "integer", "description": "Unix ms." }, + "start_date": { "type": "integer", "description": "Unix ms." }, + "time_estimate": { "type": "integer", "description": "Ms." }, + "assignees": { "type": "object", "description": "{add:[id1,id2], rem:[id3]} — incremental update of assignees (NOT a full replacement)." }, + "archived": { "type": "boolean", "description": "Archive/unarchive." } + }, + "required": ["taskId"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/task/{taskId}", + "bodyMapping": { + "name": "$name", + "description": "$description", + "status": "$status", + "priority": "$priority", + "due_date": "$due_date", + "start_date": "$start_date", + "time_estimate": "$time_estimate", + "assignees": "$assignees", + "archived": "$archived" + } + } + }, + { + "name": "clickup_delete_task", + "description": "Delete a task. Moves to trash (recoverable for 30 days from UI).", + "parameters": { + "type": "object", + "properties": { + "taskId": { "type": "string", "description": "Task ID." } + }, + "required": ["taskId"] + }, + "endpointMapping": { "method": "DELETE", "path": "/task/{taskId}" } + }, + { + "name": "clickup_create_task_comment", + "description": "Add a comment to a task.", + "parameters": { + "type": "object", + "properties": { + "taskId": { "type": "string", "description": "Task ID." }, + "comment_text": { "type": "string", "description": "Comment body (Markdown)." }, + "assignee": { "type": "integer", "description": "Optional user ID to assign as a follow-up." }, + "notify_all": { "type": "boolean", "description": "Notify watchers." } + }, + "required": ["taskId", "comment_text"] + }, + "endpointMapping": { + "method": "POST", + "path": "/task/{taskId}/comment", + "bodyMapping": { + "comment_text": "$comment_text", + "assignee": "$assignee", + "notify_all": "$notify_all" + } + } + }, + { + "name": "clickup_list_task_comments", + "description": "List comments on a task. Returns each comment's id, comment_text, assignee, resolved, date.", + "parameters": { + "type": "object", + "properties": { + "taskId": { "type": "string", "description": "Task ID." }, + "start": { "type": "integer", "description": "Pagination cursor (Unix ms of comment to start from)." } + }, + "required": ["taskId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/task/{taskId}/comment", + "queryParams": { "start": "$start" } + } + }, + { + "name": "clickup_get_list_custom_fields", + "description": "List custom fields available on a list, with their IDs and types. Required to compose custom_fields arrays.", + "parameters": { + "type": "object", + "properties": { + "listId": { "type": "string", "description": "List ID." } + }, + "required": ["listId"] + }, + "endpointMapping": { "method": "GET", "path": "/list/{listId}/field" } + }, + { + "name": "clickup_list_team_members", + "description": "List members of a workspace. Returns id (use as assignee), username, email, color, role.", + "parameters": { + "type": "object", + "properties": { + "teamId": { "type": "string", "description": "Workspace ID." } + }, + "required": ["teamId"] + }, + "endpointMapping": { "method": "GET", "path": "/team/{teamId}/member" } + } + ] +} diff --git a/packages/backend/src/adapters/intl/clickup.live.spec.ts b/packages/backend/src/adapters/intl/clickup.live.spec.ts new file mode 100644 index 0000000..a6ca1a4 --- /dev/null +++ b/packages/backend/src/adapters/intl/clickup.live.spec.ts @@ -0,0 +1,12 @@ +import * as adapter from './clickup.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: Record }; +}; +describe('clickup adapter — static spec conformance', () => { + it('api.clickup.com/api/v2', () => expect(a.connector.baseUrl).toBe('https://api.clickup.com/api/v2')); + it('Authorization header with raw token (no Bearer prefix)', () => { + expect(a.connector.authType).toBe('API_KEY'); + expect(a.connector.authConfig.headerName).toBe('Authorization'); + expect(a.connector.authConfig.apiKey).toBe('{{CLICKUP_API_TOKEN}}'); + }); +}); diff --git a/packages/backend/src/adapters/intl/coda.json b/packages/backend/src/adapters/intl/coda.json new file mode 100644 index 0000000..de90bbf --- /dev/null +++ b/packages/backend/src/adapters/intl/coda.json @@ -0,0 +1,288 @@ +{ + "slug": "coda", + "name": "Coda", + "description": "Drive Coda (docs + tables + formulas) from any AI agent: docs, tables, rows, columns, formulas, packs, controls. 13 tools, Bearer token auth.", + "instructions": "This connector uses the Coda API v1 (coda.io/developers/apis/v1).\n\n**Setup**:\n1. Sign in to https://coda.io → top-right avatar → **Account → API settings → Generate API token**.\n2. Name the token. Copy it. Set `CODA_API_TOKEN`.\n\n**Authentication**: `Authorization: Bearer ${CODA_API_TOKEN}`.\n\n**Resource hierarchy**: Doc → Section (Page) → Table → Row → Cell. Coda's twist: tables are first-class with structured columns and types; rows are addressable individually; columns can hold formulas.\n\n**Doc IDs** look like `dXXXXXXXXXX` (10-char prefix `d`). Visible in the doc URL.\n\n**Table IDs** look like `grid-XXXXX`. Discover via `coda_list_tables(docId)`.\n\n**Column IDs** look like `c-XXXX`. Discover via `coda_list_columns(docId, tableId)`. When upserting rows you reference cells by column ID OR column name.\n\n**Row IDs** look like `i-XXXX`. Available after a row is created.\n\n**Upserting rows (with key columns)**: pass `keyColumns` (array of column IDs/names) to `coda_upsert_rows` — Coda matches existing rows by those columns and updates them, otherwise inserts. Without `keyColumns`, it always inserts.\n\n**Cell value formats**:\n - Text/Number/Boolean: pass the raw value.\n - Date/DateTime: ISO 8601 string.\n - Person: pass an object `{email}`.\n - Lookup/Relation: pass the row ID of the related row OR the display value.\n - Image/Attachment: pass a public HTTPS URL.\n\n**Sync vs disableParsing**: by default Coda parses cell values (interprets formulas, types). Set `disableParsing=true` if you want raw string passthrough.\n\n**Pagination**: cursor via `pageToken` (returned in response's `nextPageToken`). `limit` default 25, max 200.\n\n**Rate limits**: 100 req/min per token. On 429 back off.\n\n**Out of scope here**: doc creation/publishing, control updates beyond list, pack version management, automations, formula evaluation arbitrary, sections beyond list.", + "region": "intl", + "category": "project-management", + "icon": "coda", + "docsUrl": "https://coda.io/developers/apis/v1", + "requiredEnvVars": ["CODA_API_TOKEN"], + "connector": { + "name": "Coda v1", + "type": "REST", + "baseUrl": "https://coda.io/apis/v1", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{CODA_API_TOKEN}}" + } + }, + "tools": [ + { + "name": "coda_whoami", + "description": "Return the user the token belongs to. Returns name, loginId (email), pictureLink, type, tokenName, workspace.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/whoami" } + }, + { + "name": "coda_list_docs", + "description": "List docs the user can access. Filter by owner, query, workspace. Pagination via pageToken.", + "parameters": { + "type": "object", + "properties": { + "isOwner": { "type": "boolean", "description": "Only docs the user owns." }, + "isPublished": { "type": "boolean", "description": "Only published docs." }, + "query": { "type": "string", "description": "Substring search across doc names." }, + "sourceDoc": { "type": "string", "description": "Filter to copies of this doc ID." }, + "isStarred": { "type": "boolean", "description": "Only starred." }, + "inGallery": { "type": "boolean", "description": "Only gallery docs." }, + "workspaceId": { "type": "string", "description": "Filter by workspace." }, + "folderId": { "type": "string", "description": "Filter by folder." }, + "limit": { "type": "integer", "description": "Max per page (default 25, max 100)." }, + "pageToken": { "type": "string", "description": "Pagination cursor." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/docs", + "queryParams": { + "isOwner": "$isOwner", + "isPublished": "$isPublished", + "query": "$query", + "sourceDoc": "$sourceDoc", + "isStarred": "$isStarred", + "inGallery": "$inGallery", + "workspaceId": "$workspaceId", + "folderId": "$folderId", + "limit": "$limit", + "pageToken": "$pageToken" + } + } + }, + { + "name": "coda_get_doc", + "description": "Fetch a single doc by ID. Returns metadata: name, owner, ownerName, browserLink, icon, workspace.", + "parameters": { + "type": "object", + "properties": { + "docId": { "type": "string", "description": "Doc ID (e.g. 'dXXXXXXXXXX')." } + }, + "required": ["docId"] + }, + "endpointMapping": { "method": "GET", "path": "/docs/{docId}" } + }, + { + "name": "coda_list_tables", + "description": "List tables in a doc. Each table has id (e.g. 'grid-XXXXX'), name, tableType (table/view), rowCount, displayColumn, sorts, layout.", + "parameters": { + "type": "object", + "properties": { + "docId": { "type": "string", "description": "Doc ID." }, + "limit": { "type": "integer", "description": "Per page." }, + "pageToken": { "type": "string", "description": "Pagination cursor." }, + "sortBy": { "type": "string", "description": "name." }, + "tableTypes": { "type": "string", "description": "Comma-separated: table,view." } + }, + "required": ["docId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/docs/{docId}/tables", + "queryParams": { + "limit": "$limit", + "pageToken": "$pageToken", + "sortBy": "$sortBy", + "tableTypes": "$tableTypes" + } + } + }, + { + "name": "coda_list_columns", + "description": "List columns in a table. Each column has id (e.g. 'c-XXXX'), name, display (true if it's the display column), calculated, formula, format (type info).", + "parameters": { + "type": "object", + "properties": { + "docId": { "type": "string", "description": "Doc ID." }, + "tableId": { "type": "string", "description": "Table ID." }, + "limit": { "type": "integer", "description": "Per page." }, + "pageToken": { "type": "string", "description": "Cursor." } + }, + "required": ["docId", "tableId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/docs/{docId}/tables/{tableId}/columns", + "queryParams": { "limit": "$limit", "pageToken": "$pageToken" } + } + }, + { + "name": "coda_list_rows", + "description": "List rows in a table or view. Supports filtering by a query like 'Status=\"Active\"' (uses Coda formula syntax with column IDs or names).", + "parameters": { + "type": "object", + "properties": { + "docId": { "type": "string", "description": "Doc ID." }, + "tableId": { "type": "string", "description": "Table ID." }, + "query": { "type": "string", "description": "Filter formula, e.g. 'c-XXXX:\"Active\"' or 'Status=\"Active\"'." }, + "useColumnNames": { "type": "boolean", "description": "If true, returned cells are keyed by column name. Default false (keyed by ID)." }, + "valueFormat": { "type": "string", "description": "Output format: simple, simpleWithArrays, rich." }, + "sortBy": { "type": "string", "description": "natural (default table order) or createdAt." }, + "visibleOnly": { "type": "boolean", "description": "Skip hidden columns." }, + "limit": { "type": "integer", "description": "Per page (default 25, max 200)." }, + "pageToken": { "type": "string", "description": "Cursor." } + }, + "required": ["docId", "tableId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/docs/{docId}/tables/{tableId}/rows", + "queryParams": { + "query": "$query", + "useColumnNames": "$useColumnNames", + "valueFormat": "$valueFormat", + "sortBy": "$sortBy", + "visibleOnly": "$visibleOnly", + "limit": "$limit", + "pageToken": "$pageToken" + } + } + }, + { + "name": "coda_get_row", + "description": "Fetch a single row by ID. Returns cells keyed by column ID (or name if useColumnNames=true).", + "parameters": { + "type": "object", + "properties": { + "docId": { "type": "string", "description": "Doc ID." }, + "tableId": { "type": "string", "description": "Table ID." }, + "rowId": { "type": "string", "description": "Row ID (e.g. 'i-XXXX')." }, + "useColumnNames": { "type": "boolean", "description": "Key cells by name vs ID." }, + "valueFormat": { "type": "string", "description": "simple, simpleWithArrays, rich." } + }, + "required": ["docId", "tableId", "rowId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/docs/{docId}/tables/{tableId}/rows/{rowId}", + "queryParams": { + "useColumnNames": "$useColumnNames", + "valueFormat": "$valueFormat" + } + } + }, + { + "name": "coda_insert_rows", + "description": "Insert (or upsert with keyColumns) rows into a table. Returns a requestId for tracking the async ingest job.", + "parameters": { + "type": "object", + "properties": { + "docId": { "type": "string", "description": "Doc ID." }, + "tableId": { "type": "string", "description": "Table ID." }, + "rows": { + "type": "array", + "description": "Array of row objects: [{cells: [{column: 'c-XXXX' or column-name, value: ...}, ...]}, ...]. value can be string/number/boolean OR object for person ({email}) / row link (rowId string)." + }, + "keyColumns": { "type": "array", "description": "Array of column IDs/names to match for upsert. Omit for pure insert." }, + "disableParsing": { "type": "boolean", "description": "If true, send raw values without Coda parsing." } + }, + "required": ["docId", "tableId", "rows"] + }, + "endpointMapping": { + "method": "POST", + "path": "/docs/{docId}/tables/{tableId}/rows", + "bodyMapping": { + "rows": "$rows", + "keyColumns": "$keyColumns" + }, + "queryParams": { "disableParsing": "$disableParsing" } + } + }, + { + "name": "coda_update_row", + "description": "Update a specific row by ID. Returns a requestId for tracking async update.", + "parameters": { + "type": "object", + "properties": { + "docId": { "type": "string", "description": "Doc ID." }, + "tableId": { "type": "string", "description": "Table ID." }, + "rowId": { "type": "string", "description": "Row ID." }, + "row": { + "type": "object", + "description": "{cells: [{column: 'c-XXXX' or name, value: ...}, ...]}." + }, + "disableParsing": { "type": "boolean", "description": "Skip parsing." } + }, + "required": ["docId", "tableId", "rowId", "row"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/docs/{docId}/tables/{tableId}/rows/{rowId}", + "bodyMapping": { "row": "$row" }, + "queryParams": { "disableParsing": "$disableParsing" } + } + }, + { + "name": "coda_delete_row", + "description": "Delete a single row by ID.", + "parameters": { + "type": "object", + "properties": { + "docId": { "type": "string", "description": "Doc ID." }, + "tableId": { "type": "string", "description": "Table ID." }, + "rowId": { "type": "string", "description": "Row ID." } + }, + "required": ["docId", "tableId", "rowId"] + }, + "endpointMapping": { + "method": "DELETE", + "path": "/docs/{docId}/tables/{tableId}/rows/{rowId}" + } + }, + { + "name": "coda_delete_rows", + "description": "Bulk-delete rows by IDs (more efficient than per-row).", + "parameters": { + "type": "object", + "properties": { + "docId": { "type": "string", "description": "Doc ID." }, + "tableId": { "type": "string", "description": "Table ID." }, + "rowIds": { "type": "array", "description": "Array of row IDs to delete." } + }, + "required": ["docId", "tableId", "rowIds"] + }, + "endpointMapping": { + "method": "DELETE", + "path": "/docs/{docId}/tables/{tableId}/rows", + "bodyMapping": { "rowIds": "$rowIds" } + } + }, + { + "name": "coda_get_formula_result", + "description": "Evaluate a named formula on a doc. Returns the current value of a formula by name (defined in the doc via Add Formula).", + "parameters": { + "type": "object", + "properties": { + "docId": { "type": "string", "description": "Doc ID." }, + "formulaIdOrName": { "type": "string", "description": "Formula ID or name (URL-encoded)." } + }, + "required": ["docId", "formulaIdOrName"] + }, + "endpointMapping": { + "method": "GET", + "path": "/docs/{docId}/formulas/{formulaIdOrName}" + } + }, + { + "name": "coda_get_mutation_status", + "description": "Check the status of an async mutation (insert/update/delete) by its requestId returned from those calls. Returns completed boolean.", + "parameters": { + "type": "object", + "properties": { + "requestId": { "type": "string", "description": "RequestId from a prior mutation call." } + }, + "required": ["requestId"] + }, + "endpointMapping": { "method": "GET", "path": "/mutationStatus/{requestId}" } + } + ] +} diff --git a/packages/backend/src/adapters/intl/coda.live.spec.ts b/packages/backend/src/adapters/intl/coda.live.spec.ts new file mode 100644 index 0000000..2d3a8d7 --- /dev/null +++ b/packages/backend/src/adapters/intl/coda.live.spec.ts @@ -0,0 +1,8 @@ +import * as adapter from './coda.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: Record }; +}; +describe('coda adapter — static spec conformance', () => { + it('coda.io/apis/v1', () => expect(a.connector.baseUrl).toBe('https://coda.io/apis/v1')); + it('Bearer auth', () => expect(a.connector.authType).toBe('BEARER_TOKEN')); +}); diff --git a/packages/backend/src/adapters/intl/todoist.json b/packages/backend/src/adapters/intl/todoist.json new file mode 100644 index 0000000..1cab610 --- /dev/null +++ b/packages/backend/src/adapters/intl/todoist.json @@ -0,0 +1,254 @@ +{ + "slug": "todoist", + "name": "Todoist", + "description": "Drive Todoist (personal/team task manager) from any AI agent: projects, tasks, comments, labels, sections. 12 tools, Bearer token auth, REST API v2.", + "instructions": "This connector uses the Todoist API v1 (the modern unified API — the older `/rest/v2` and `/sync/v9` are deprecated).\n\n**Setup**:\n1. Sign in to https://todoist.com → top-right avatar → **Settings → Integrations → Developer → API token → Copy**.\n2. Set `TODOIST_API_TOKEN` to it.\n\n**Authentication**: `Authorization: Bearer ${TODOIST_API_TOKEN}`. The token is per-user; everything done with it appears as that user's actions. Personal API tokens never expire.\n\n**Hierarchy**: Project → Section (optional) → Task → Sub-task. Tasks can also live directly in a project without a section. The Inbox is a special project (`inbox_project=true`).\n\n**Date strings**: Todoist accepts natural language for `due_string` (e.g. 'tomorrow at noon', 'every monday 9am', 'in 3 days', 'next friday') OR explicit `due_date` (YYYY-MM-DD) / `due_datetime` (ISO 8601 RFC 3339). For recurring tasks, the natural-language form is required to set recurrence rules.\n\n**Priorities**: 1-4 in the API (1=lowest, 4=urgent). The Todoist UI shows them inverted (P1 in UI = priority 4 in API).\n\n**Filters**: when listing tasks via `todoist_get_active_tasks`, the `filter` parameter accepts Todoist's filter syntax: 'today', 'overdue', 'p1 & @work', 'due before: +7 days', etc.\n\n**Pagination**: not used — the REST API returns full lists (sometimes thousands of items). Use filters to narrow.\n\n**Rate limits**: 1000 requests per 15 min per user. On 429 back off.\n\n**Sync API not exposed here**: Todoist also has a Sync API (single batch endpoint that returns all data at once) — useful for full local mirrors but more complex. This connector uses the simpler REST endpoints.\n\n**Out of scope here**: webhooks, attachments upload, project sharing/collaboration changes (only show collaborators), karma stats, premium-only filters management.", + "region": "intl", + "category": "project-management", + "icon": "todoist", + "docsUrl": "https://developer.todoist.com/rest/v2", + "requiredEnvVars": ["TODOIST_API_TOKEN"], + "connector": { + "name": "Todoist API v1", + "type": "REST", + "baseUrl": "https://api.todoist.com/api/v1", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{TODOIST_API_TOKEN}}" + } + }, + "tools": [ + { + "name": "todoist_get_projects", + "description": "List all projects the user can access. Each project has id, name, color, parent_id, order, comment_count, is_shared, is_favorite, is_inbox_project, url.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/projects" } + }, + { + "name": "todoist_create_project", + "description": "Create a new project. Required: name. Color names: berry_red, red, orange, yellow, olive_green, lime_green, green, mint_green, teal, sky_blue, light_blue, blue, grape, violet, lavender, magenta, salmon, charcoal, grey, taupe.", + "parameters": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Project name." }, + "parent_id": { "type": "string", "description": "Parent project ID (nest as sub-project)." }, + "color": { "type": "string", "description": "Color name (see description)." }, + "is_favorite": { "type": "boolean", "description": "Add to favorites." }, + "view_style": { "type": "string", "description": "list (default) or board (Kanban view)." } + }, + "required": ["name"] + }, + "endpointMapping": { + "method": "POST", + "path": "/projects", + "bodyMapping": { + "name": "$name", + "parent_id": "$parent_id", + "color": "$color", + "is_favorite": "$is_favorite", + "view_style": "$view_style" + } + } + }, + { + "name": "todoist_get_active_tasks", + "description": "List active (non-completed) tasks with filters. The `filter` parameter is powerful — use Todoist filter syntax like 'today', 'overdue', 'p1 & @home', 'due before: +7 days'.", + "parameters": { + "type": "object", + "properties": { + "project_id": { "type": "string", "description": "Filter by project ID." }, + "section_id": { "type": "string", "description": "Filter by section ID." }, + "label": { "type": "string", "description": "Filter by label name." }, + "filter": { "type": "string", "description": "Todoist filter string, e.g. 'today', 'overdue', '@work & p1', 'due before: +7 days'." }, + "ids": { "type": "string", "description": "Comma-separated specific task IDs." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/tasks", + "queryParams": { + "project_id": "$project_id", + "section_id": "$section_id", + "label": "$label", + "filter": "$filter", + "ids": "$ids" + } + } + }, + { + "name": "todoist_get_task", + "description": "Fetch a single task by ID.", + "parameters": { + "type": "object", + "properties": { + "taskId": { "type": "string", "description": "Task ID." } + }, + "required": ["taskId"] + }, + "endpointMapping": { "method": "GET", "path": "/tasks/{taskId}" } + }, + { + "name": "todoist_create_task", + "description": "Create a task. Required: content. Use due_string for natural-language dates ('tomorrow 9am', 'every monday'). Set parent_id to create a subtask.", + "parameters": { + "type": "object", + "properties": { + "content": { "type": "string", "description": "Task content (the title). Supports Markdown links." }, + "description": { "type": "string", "description": "Longer description (Markdown)." }, + "project_id": { "type": "string", "description": "Target project ID. Defaults to Inbox." }, + "section_id": { "type": "string", "description": "Section within the project." }, + "parent_id": { "type": "string", "description": "Make this a subtask of the given task ID." }, + "order": { "type": "integer", "description": "Order within the parent." }, + "labels": { "type": "array", "description": "Array of label names (auto-creates labels if new)." }, + "priority": { "type": "integer", "description": "1-4. 4 = highest (UI P1)." }, + "due_string": { "type": "string", "description": "Natural language: 'tomorrow at 9am', 'every monday', 'in 3 days'. Required for recurring tasks." }, + "due_date": { "type": "string", "description": "YYYY-MM-DD." }, + "due_datetime": { "type": "string", "description": "ISO 8601 with timezone." }, + "due_lang": { "type": "string", "description": "Language for due_string parsing (en, de, fr, ru, ja, etc.)." }, + "assignee_id": { "type": "string", "description": "User ID — only valid for shared projects." }, + "duration": { "type": "integer", "description": "Duration in minutes (for time-blocking)." }, + "duration_unit": { "type": "string", "description": "minute or day." } + }, + "required": ["content"] + }, + "endpointMapping": { + "method": "POST", + "path": "/tasks", + "bodyMapping": { + "content": "$content", + "description": "$description", + "project_id": "$project_id", + "section_id": "$section_id", + "parent_id": "$parent_id", + "order": "$order", + "labels": "$labels", + "priority": "$priority", + "due_string": "$due_string", + "due_date": "$due_date", + "due_datetime": "$due_datetime", + "due_lang": "$due_lang", + "assignee_id": "$assignee_id", + "duration": "$duration", + "duration_unit": "$duration_unit" + } + } + }, + { + "name": "todoist_update_task", + "description": "Update a task. To move it to a different project, use the dedicated todoist_move_task tool.", + "parameters": { + "type": "object", + "properties": { + "taskId": { "type": "string", "description": "Task ID." }, + "content": { "type": "string", "description": "New content." }, + "description": { "type": "string", "description": "New description." }, + "labels": { "type": "array", "description": "Replace labels." }, + "priority": { "type": "integer", "description": "1-4." }, + "due_string": { "type": "string", "description": "Natural-language date." }, + "due_date": { "type": "string", "description": "YYYY-MM-DD." }, + "due_datetime": { "type": "string", "description": "ISO 8601." }, + "assignee_id": { "type": "string", "description": "Reassign (shared projects)." }, + "duration": { "type": "integer", "description": "Duration minutes." }, + "duration_unit": { "type": "string", "description": "minute or day." } + }, + "required": ["taskId"] + }, + "endpointMapping": { + "method": "POST", + "path": "/tasks/{taskId}", + "bodyMapping": { + "content": "$content", + "description": "$description", + "labels": "$labels", + "priority": "$priority", + "due_string": "$due_string", + "due_date": "$due_date", + "due_datetime": "$due_datetime", + "assignee_id": "$assignee_id", + "duration": "$duration", + "duration_unit": "$duration_unit" + } + } + }, + { + "name": "todoist_complete_task", + "description": "Mark a task as completed. For recurring tasks, the next occurrence is auto-created.", + "parameters": { + "type": "object", + "properties": { + "taskId": { "type": "string", "description": "Task ID." } + }, + "required": ["taskId"] + }, + "endpointMapping": { "method": "POST", "path": "/tasks/{taskId}/close" } + }, + { + "name": "todoist_reopen_task", + "description": "Reopen a previously-completed task.", + "parameters": { + "type": "object", + "properties": { + "taskId": { "type": "string", "description": "Task ID." } + }, + "required": ["taskId"] + }, + "endpointMapping": { "method": "POST", "path": "/tasks/{taskId}/reopen" } + }, + { + "name": "todoist_delete_task", + "description": "Permanently delete a task. Irreversible.", + "parameters": { + "type": "object", + "properties": { + "taskId": { "type": "string", "description": "Task ID." } + }, + "required": ["taskId"] + }, + "endpointMapping": { "method": "DELETE", "path": "/tasks/{taskId}" } + }, + { + "name": "todoist_get_comments", + "description": "List comments on a project OR a task. Provide either project_id or task_id.", + "parameters": { + "type": "object", + "properties": { + "project_id": { "type": "string", "description": "Project ID." }, + "task_id": { "type": "string", "description": "Task ID." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/comments", + "queryParams": { "project_id": "$project_id", "task_id": "$task_id" } + } + }, + { + "name": "todoist_create_comment", + "description": "Add a comment to a project or task. Required: content + one of (project_id, task_id).", + "parameters": { + "type": "object", + "properties": { + "task_id": { "type": "string", "description": "Task ID (mutually exclusive with project_id)." }, + "project_id": { "type": "string", "description": "Project ID." }, + "content": { "type": "string", "description": "Comment body (Markdown)." } + }, + "required": ["content"] + }, + "endpointMapping": { + "method": "POST", + "path": "/comments", + "bodyMapping": { + "task_id": "$task_id", + "project_id": "$project_id", + "content": "$content" + } + } + }, + { + "name": "todoist_get_labels", + "description": "List the user's personal labels. Each has id, name, color, order, is_favorite.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/labels" } + } + ] +} diff --git a/packages/backend/src/adapters/intl/todoist.live.spec.ts b/packages/backend/src/adapters/intl/todoist.live.spec.ts new file mode 100644 index 0000000..307fe81 --- /dev/null +++ b/packages/backend/src/adapters/intl/todoist.live.spec.ts @@ -0,0 +1,11 @@ +import * as adapter from './todoist.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: Record }; +}; +describe('todoist adapter — static spec conformance', () => { + it('api.todoist.com/api/v1 (rest/v2 is deprecated)', () => expect(a.connector.baseUrl).toBe('https://api.todoist.com/api/v1')); + it('Bearer auth', () => { + expect(a.connector.authType).toBe('BEARER_TOKEN'); + expect(a.connector.authConfig.token).toBe('{{TODOIST_API_TOKEN}}'); + }); +}); diff --git a/packages/backend/src/adapters/intl/trello.json b/packages/backend/src/adapters/intl/trello.json new file mode 100644 index 0000000..e499e75 --- /dev/null +++ b/packages/backend/src/adapters/intl/trello.json @@ -0,0 +1,301 @@ +{ + "slug": "trello", + "name": "Trello", + "description": "Drive Trello (Kanban boards) from any AI agent: boards, lists, cards, members, labels, checklists, attachments-by-URL. 13 tools. API key + user token query-string auth (Trello-specific).", + "instructions": "This connector uses the Trello REST API v1.\n\n**Setup**:\n1. Sign in to Trello → go to https://trello.com/power-ups/admin → **+ New** to create a Power-Up (this is just the modern way to mint API keys).\n2. After creation, open the Power-Up → **API key** section → copy the **API key**.\n3. Below it, click **Token** → authorize the Power-Up → copy the generated user **Token**.\n4. Set:\n - `TRELLO_API_KEY` = the Power-Up key (a long alphanumeric)\n - `TRELLO_TOKEN` = the user token (much longer)\n\n**Authentication**: query-string `?key=${TRELLO_API_KEY}&token=${TRELLO_TOKEN}` on every request. The token is per-user — actions are attributed to that user. To act as a different user, mint a separate token via their Trello account.\n\n**Hierarchy**: Workspace (Organization) → Board → List → Card. Cards have members[], labels[], checklists[], attachments[], comments (= actions of type commentCard).\n\n**ID format**: 24-character hex (e.g. `abc123def456789012345678`). Boards / lists / cards / members all use this format. Discover board IDs via `trello_list_my_boards` → list IDs via `trello_get_board_lists` → card IDs via `trello_list_cards_in_list`.\n\n**`fields` parameter**: virtually every endpoint accepts `?fields=all` or `?fields=comma,separated,list`. Default returns a sensible subset — for full payloads pass `fields=all`.\n\n**Position values**: lists and cards have a `pos` (float) for ordering. `pos=top` or `pos=bottom` shortcuts work for moves; otherwise pass an explicit number between the neighbors' positions.\n\n**Labels**: are board-level. A label has color (green/yellow/orange/red/purple/blue/sky/lime/pink/black/null) + name. Cards reference labels by their ID. Use `trello_list_board_labels` to discover IDs.\n\n**Attachments**: upload via multipart not supported in single call. Use URL attachments via the `url` parameter — Trello fetches it.\n\n**Webhooks** out of scope.\n\n**Rate limits**: 100 req per 10 sec per API key, 300 per 10 sec per token. Returns 429 with `X-Rate-Limit-Api-Key-Remaining` header.\n\n**Out of scope here**: Power-Up actions, custom fields (Power-Up only), board backgrounds, voting, plugin data, butler automations.", + "region": "intl", + "category": "project-management", + "icon": "trello", + "docsUrl": "https://developer.atlassian.com/cloud/trello/rest/", + "requiredEnvVars": ["TRELLO_API_KEY", "TRELLO_TOKEN"], + "connector": { + "name": "Trello v1", + "type": "REST", + "baseUrl": "https://api.trello.com/1", + "authType": "QUERY_AUTH", + "authConfig": { + "key": "{{TRELLO_API_KEY}}", + "token": "{{TRELLO_TOKEN}}" + } + }, + "tools": [ + { + "name": "trello_get_me", + "description": "Return the user the token belongs to: id, username, fullName, email, avatarUrl. Health check + whoami.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/members/me" } + }, + { + "name": "trello_list_my_boards", + "description": "List boards the user is a member of. Each board has id, name, desc, url, closed, idOrganization.", + "parameters": { + "type": "object", + "properties": { + "filter": { "type": "string", "description": "all, members, organization, public, open, closed, pinned, unpinned, starred." }, + "fields": { "type": "string", "description": "all or comma-separated list." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/members/me/boards", + "queryParams": { "filter": "$filter", "fields": "$fields" } + } + }, + { + "name": "trello_get_board", + "description": "Fetch a board with optional nested resources (lists/cards/labels/members) via inclusion params.", + "parameters": { + "type": "object", + "properties": { + "boardId": { "type": "string", "description": "Board ID." }, + "fields": { "type": "string", "description": "Board fields or 'all'." }, + "lists": { "type": "string", "description": "Include lists: none, all, open, closed." }, + "cards": { "type": "string", "description": "Include cards: none, all, open, closed, visible." }, + "members": { "type": "string", "description": "Include members: none, normal, admins, owners, all." }, + "labels": { "type": "string", "description": "Include labels: none, all." } + }, + "required": ["boardId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/boards/{boardId}", + "queryParams": { + "fields": "$fields", + "lists": "$lists", + "cards": "$cards", + "members": "$members", + "labels": "$labels" + } + } + }, + { + "name": "trello_get_board_lists", + "description": "List the lists (columns) on a board.", + "parameters": { + "type": "object", + "properties": { + "boardId": { "type": "string", "description": "Board ID." }, + "filter": { "type": "string", "description": "all, open, closed, none." }, + "fields": { "type": "string", "description": "Fields or 'all'." } + }, + "required": ["boardId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/boards/{boardId}/lists", + "queryParams": { "filter": "$filter", "fields": "$fields" } + } + }, + { + "name": "trello_list_cards_in_list", + "description": "List cards in a list. Each card has id, name, desc, idMembers, idLabels, due, dueComplete, shortUrl, pos.", + "parameters": { + "type": "object", + "properties": { + "listId": { "type": "string", "description": "List ID." }, + "fields": { "type": "string", "description": "Card fields or 'all'." } + }, + "required": ["listId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/lists/{listId}/cards", + "queryParams": { "fields": "$fields" } + } + }, + { + "name": "trello_get_card", + "description": "Fetch a single card with optional members/labels/checklists/attachments included.", + "parameters": { + "type": "object", + "properties": { + "cardId": { "type": "string", "description": "Card ID." }, + "fields": { "type": "string", "description": "Card fields." }, + "members": { "type": "boolean", "description": "Include full member objects." }, + "checklists": { "type": "string", "description": "all or none." }, + "attachments": { "type": "string", "description": "true, false, cover." } + }, + "required": ["cardId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/cards/{cardId}", + "queryParams": { + "fields": "$fields", + "members": "$members", + "checklists": "$checklists", + "attachments": "$attachments" + } + } + }, + { + "name": "trello_create_card", + "description": "Create a card in a list. Required: idList + name. due is ISO 8601. idMembers/idLabels are comma-separated ID strings.", + "parameters": { + "type": "object", + "properties": { + "idList": { "type": "string", "description": "Target list ID." }, + "name": { "type": "string", "description": "Card name." }, + "desc": { "type": "string", "description": "Description (Markdown)." }, + "pos": { "type": "string", "description": "Position: 'top', 'bottom', or numeric (e.g. '65535')." }, + "due": { "type": "string", "description": "Due date ISO 8601." }, + "dueComplete": { "type": "boolean", "description": "If true, due is marked complete." }, + "idMembers": { "type": "string", "description": "Comma-separated member IDs." }, + "idLabels": { "type": "string", "description": "Comma-separated label IDs." }, + "urlSource": { "type": "string", "description": "If set, attach this URL to the card on create." }, + "address": { "type": "string", "description": "Location address." }, + "locationName": { "type": "string", "description": "Location name." }, + "coordinates": { "type": "string", "description": "lat,lng coordinates." } + }, + "required": ["idList", "name"] + }, + "endpointMapping": { + "method": "POST", + "path": "/cards", + "bodyMapping": { + "idList": "$idList", + "name": "$name", + "desc": "$desc", + "pos": "$pos", + "due": "$due", + "dueComplete": "$dueComplete", + "idMembers": "$idMembers", + "idLabels": "$idLabels", + "urlSource": "$urlSource", + "address": "$address", + "locationName": "$locationName", + "coordinates": "$coordinates" + } + } + }, + { + "name": "trello_update_card", + "description": "Update a card. Common: idList (move to another column), pos, due/dueComplete, name/desc, idMembers (replace whole array via comma-separated string), idLabels.", + "parameters": { + "type": "object", + "properties": { + "cardId": { "type": "string", "description": "Card ID." }, + "name": { "type": "string", "description": "New name." }, + "desc": { "type": "string", "description": "New description." }, + "closed": { "type": "boolean", "description": "Archive the card (true = archive)." }, + "idMembers": { "type": "string", "description": "Comma-separated member IDs (full replacement)." }, + "idAttachmentCover": { "type": "string", "description": "Attachment ID to use as cover." }, + "idList": { "type": "string", "description": "Move to this list ID." }, + "idLabels": { "type": "string", "description": "Comma-separated label IDs (full replacement)." }, + "idBoard": { "type": "string", "description": "Move to a different board." }, + "pos": { "type": "string", "description": "top, bottom, or numeric." }, + "due": { "type": "string", "description": "ISO 8601 or null to clear." }, + "dueComplete": { "type": "boolean", "description": "Mark due complete." } + }, + "required": ["cardId"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/cards/{cardId}", + "bodyMapping": { + "name": "$name", + "desc": "$desc", + "closed": "$closed", + "idMembers": "$idMembers", + "idAttachmentCover": "$idAttachmentCover", + "idList": "$idList", + "idLabels": "$idLabels", + "idBoard": "$idBoard", + "pos": "$pos", + "due": "$due", + "dueComplete": "$dueComplete" + } + } + }, + { + "name": "trello_delete_card", + "description": "Permanently delete a card. Use trello_update_card with closed=true for archive (recoverable).", + "parameters": { + "type": "object", + "properties": { + "cardId": { "type": "string", "description": "Card ID." } + }, + "required": ["cardId"] + }, + "endpointMapping": { "method": "DELETE", "path": "/cards/{cardId}" } + }, + { + "name": "trello_add_card_comment", + "description": "Add a comment to a card (creates an action of type commentCard).", + "parameters": { + "type": "object", + "properties": { + "cardId": { "type": "string", "description": "Card ID." }, + "text": { "type": "string", "description": "Comment text (Markdown)." } + }, + "required": ["cardId", "text"] + }, + "endpointMapping": { + "method": "POST", + "path": "/cards/{cardId}/actions/comments", + "bodyMapping": { "text": "$text" } + } + }, + { + "name": "trello_attach_url_to_card", + "description": "Attach a URL (link) to a card. For file uploads use the Trello UI — multipart upload via API is not supported here.", + "parameters": { + "type": "object", + "properties": { + "cardId": { "type": "string", "description": "Card ID." }, + "url": { "type": "string", "description": "URL to attach." }, + "name": { "type": "string", "description": "Display name for the attachment." }, + "setCover": { "type": "boolean", "description": "If true and the URL is an image, use it as the card cover." } + }, + "required": ["cardId", "url"] + }, + "endpointMapping": { + "method": "POST", + "path": "/cards/{cardId}/attachments", + "bodyMapping": { + "url": "$url", + "name": "$name", + "setCover": "$setCover" + } + } + }, + { + "name": "trello_list_board_labels", + "description": "List labels available on a board. Returns id, name, color.", + "parameters": { + "type": "object", + "properties": { + "boardId": { "type": "string", "description": "Board ID." } + }, + "required": ["boardId"] + }, + "endpointMapping": { "method": "GET", "path": "/boards/{boardId}/labels" } + }, + { + "name": "trello_search", + "description": "Universal search across boards, cards, members and organizations. Supports the same operators as the Trello UI search (board:X, list:Y, label:Z, member:W, due:next-week, has:attachments, etc.).", + "parameters": { + "type": "object", + "properties": { + "query": { "type": "string", "description": "Search query (min 1 char)." }, + "modelTypes": { "type": "string", "description": "Comma-separated: actions,boards,cards,members,organizations." }, + "cards_limit": { "type": "integer", "description": "Max cards returned (default 10, max 1000)." }, + "boards_limit": { "type": "integer", "description": "Max boards." }, + "partial": { "type": "boolean", "description": "Partial match (default true)." } + }, + "required": ["query"] + }, + "endpointMapping": { + "method": "GET", + "path": "/search", + "queryParams": { + "query": "$query", + "modelTypes": "$modelTypes", + "cards_limit": "$cards_limit", + "boards_limit": "$boards_limit", + "partial": "$partial" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/trello.live.spec.ts b/packages/backend/src/adapters/intl/trello.live.spec.ts new file mode 100644 index 0000000..c3a51e2 --- /dev/null +++ b/packages/backend/src/adapters/intl/trello.live.spec.ts @@ -0,0 +1,12 @@ +import * as adapter from './trello.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: Record }; +}; +describe('trello adapter — static spec conformance', () => { + it('api.trello.com/1', () => expect(a.connector.baseUrl).toBe('https://api.trello.com/1')); + it('QUERY_AUTH with key + token', () => { + expect(a.connector.authType).toBe('QUERY_AUTH'); + expect(a.connector.authConfig.key).toBe('{{TRELLO_API_KEY}}'); + expect(a.connector.authConfig.token).toBe('{{TRELLO_TOKEN}}'); + }); +}); From dcfdac105fb97c3baffa81f0b5da14321e931cd9 Mon Sep 17 00:00:00 2001 From: Matteo Date: Wed, 20 May 2026 12:41:52 +0200 Subject: [PATCH 08/19] connectors: add Hunter, NeverBounce, Mapbox, Nominatim, Mintlify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 5 — utility batch (enrichment + maps + docs). - Hunter.io v2: 7 tools — domain-search, email-finder, email-verifier, combined person+company enrichment. QUERY_AUTH with api_key. - NeverBounce v4.2: 6 tools — single sync check + bulk async job flow (create/parse/start/status/results). QUERY_AUTH with key. - Mapbox: 8 tools — forward/reverse geocoding v6, directions, isochrones, matrix, static images, tilequery, list styles. Documents the lng,lat coordinate convention quirk. - Nominatim (OpenStreetMap, FREE no-auth): 4 tools — search, reverse, lookup by OSM ID, status. authType=NONE. Every tool pins User-Agent (Nominatim usage policy requirement). Instructions document the 1 req/sec self-throttling requirement. - Mintlify v1: 5 tools — trigger update, project info, assistant conversations log, search-queries analytics, site analytics. Catalog: 70 adapters (30/81 of the greenfield batch done). Smoke-test summary: Hunter+Mapbox 401 (auth rejected, path OK); NeverBounce 200 (endpoint public-by-default for /account); Nominatim 200 (truly public). --- packages/backend/src/adapters/catalog.ts | 10 + .../backend/src/adapters/intl/hunter.json | 153 +++++++++++++ .../src/adapters/intl/hunter.live.spec.ts | 11 + .../backend/src/adapters/intl/mapbox.json | 213 ++++++++++++++++++ .../src/adapters/intl/mapbox.live.spec.ts | 11 + .../backend/src/adapters/intl/mintlify.json | 102 +++++++++ .../src/adapters/intl/mintlify.live.spec.ts | 8 + .../src/adapters/intl/neverbounce.json | 145 ++++++++++++ .../adapters/intl/neverbounce.live.spec.ts | 11 + .../backend/src/adapters/intl/nominatim.json | 128 +++++++++++ .../src/adapters/intl/nominatim.live.spec.ts | 14 ++ 11 files changed, 806 insertions(+) create mode 100644 packages/backend/src/adapters/intl/hunter.json create mode 100644 packages/backend/src/adapters/intl/hunter.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/mapbox.json create mode 100644 packages/backend/src/adapters/intl/mapbox.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/mintlify.json create mode 100644 packages/backend/src/adapters/intl/mintlify.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/neverbounce.json create mode 100644 packages/backend/src/adapters/intl/neverbounce.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/nominatim.json create mode 100644 packages/backend/src/adapters/intl/nominatim.live.spec.ts diff --git a/packages/backend/src/adapters/catalog.ts b/packages/backend/src/adapters/catalog.ts index 170c949..6de9628 100644 --- a/packages/backend/src/adapters/catalog.ts +++ b/packages/backend/src/adapters/catalog.ts @@ -42,11 +42,16 @@ import * as coda from './intl/coda.json'; import * as convertkit from './intl/convertkit.json'; import * as copper from './intl/copper.json'; import * as discordBot from './intl/discord-bot.json'; +import * as hunter from './intl/hunter.json'; import * as klaviyo from './intl/klaviyo.json'; import * as lemlist from './intl/lemlist.json'; import * as lemonsqueezy from './intl/lemonsqueezy.json'; import * as loops from './intl/loops.json'; import * as mailchimp from './intl/mailchimp.json'; +import * as mapbox from './intl/mapbox.json'; +import * as mintlify from './intl/mintlify.json'; +import * as neverbounce from './intl/neverbounce.json'; +import * as nominatim from './intl/nominatim.json'; import * as outreach from './intl/outreach.json'; import * as pipedrive from './intl/pipedrive.json'; import * as salesloft from './intl/salesloft.json'; @@ -179,11 +184,16 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ convertkit as unknown as AdapterDefinition, copper as unknown as AdapterDefinition, discordBot as unknown as AdapterDefinition, + hunter as unknown as AdapterDefinition, klaviyo as unknown as AdapterDefinition, lemlist as unknown as AdapterDefinition, lemonsqueezy as unknown as AdapterDefinition, loops as unknown as AdapterDefinition, mailchimp as unknown as AdapterDefinition, + mapbox as unknown as AdapterDefinition, + mintlify as unknown as AdapterDefinition, + neverbounce as unknown as AdapterDefinition, + nominatim as unknown as AdapterDefinition, outreach as unknown as AdapterDefinition, pipedrive as unknown as AdapterDefinition, salesloft as unknown as AdapterDefinition, diff --git a/packages/backend/src/adapters/intl/hunter.json b/packages/backend/src/adapters/intl/hunter.json new file mode 100644 index 0000000..0a92365 --- /dev/null +++ b/packages/backend/src/adapters/intl/hunter.json @@ -0,0 +1,153 @@ +{ + "slug": "hunter", + "name": "Hunter.io", + "description": "Find and verify email addresses via Hunter.io. 7 tools for domain search, email finder, email verifier, account stats. Bearer-token / query-key auth.", + "instructions": "This connector uses the Hunter.io API v2 (hunter.io/api-documentation/v2).\n\n**Setup**:\n1. Sign in to https://hunter.io → top-right avatar → **API → API Keys**.\n2. Copy the key. Set `HUNTER_API_KEY`.\n\n**Authentication**: query-string `?api_key=...` on every call (QUERY_AUTH). Hunter also accepts the API key in an `X-API-Key` header but query-string is the documented default.\n\n**Credits model**: every request consumes credits per your plan. `domain_search` is the most expensive (returns up to 100 emails per call); `email_verifier` and `email_finder` are 1 credit each. `email_count` and `account` are FREE (don't deduct).\n\n**Confidence scores**: every email returned has a `confidence` 0-100 — Hunter's estimate of deliverability. >90 = reliable, 50-90 = use with care, <50 = high bounce risk.\n\n**Verifier vs Finder**:\n - `email_verifier`: 'is this email deliverable?' — returns status (valid/invalid/accept_all/disposable/webmail/unknown).\n - `email_finder`: 'find the email for this person at this company' — returns the most-likely email pattern with confidence.\n\n**Out of scope here**: Hunter Campaigns (their cold-email product), Leads (CRM-lite), webhooks.", + "region": "intl", + "category": "enrichment", + "icon": "hunter", + "docsUrl": "https://hunter.io/api-documentation/v2", + "requiredEnvVars": ["HUNTER_API_KEY"], + "connector": { + "name": "Hunter.io v2", + "type": "REST", + "baseUrl": "https://api.hunter.io/v2", + "authType": "QUERY_AUTH", + "authConfig": { + "api_key": "{{HUNTER_API_KEY}}" + } + }, + "tools": [ + { + "name": "hunter_account", + "description": "Return account info: email, plan_name, plan_level, calls.used, calls.available. FREE — doesn't consume credits. Use at startup to verify credentials and check remaining quota.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/account" } + }, + { + "name": "hunter_domain_search", + "description": "Find email addresses associated with a domain. Returns up to 100 emails (per_page param) with name, position, seniority, department, confidence, sources. The most credit-expensive endpoint.", + "parameters": { + "type": "object", + "properties": { + "domain": { "type": "string", "description": "Domain to search (e.g. 'acme.com'). Mutually exclusive with company." }, + "company": { "type": "string", "description": "Company name (Hunter resolves to domain)." }, + "limit": { "type": "integer", "description": "Max emails to return (default 10, max 100)." }, + "offset": { "type": "integer", "description": "Pagination offset." }, + "type": { "type": "string", "description": "Filter by type: personal or generic." }, + "seniority": { "type": "string", "description": "Filter: junior, senior, executive (comma-separated)." }, + "department": { "type": "string", "description": "Filter by department: executive, it, finance, management, sales, legal, support, hr, marketing, communication, education, design, health, operations (comma-separated)." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/domain-search", + "queryParams": { + "domain": "$domain", + "company": "$company", + "limit": "$limit", + "offset": "$offset", + "type": "$type", + "seniority": "$seniority", + "department": "$department" + } + } + }, + { + "name": "hunter_email_finder", + "description": "Find the most-likely email for a specific person at a domain. Required: domain + (first_name+last_name OR full_name). Returns email + confidence + sources.", + "parameters": { + "type": "object", + "properties": { + "domain": { "type": "string", "description": "Domain (e.g. 'acme.com'). Mutually exclusive with company." }, + "company": { "type": "string", "description": "Company name." }, + "first_name": { "type": "string", "description": "First name." }, + "last_name": { "type": "string", "description": "Last name." }, + "full_name": { "type": "string", "description": "Or full name as single string." }, + "max_duration": { "type": "integer", "description": "Max seconds Hunter spends searching (3-20, default 10)." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/email-finder", + "queryParams": { + "domain": "$domain", + "company": "$company", + "first_name": "$first_name", + "last_name": "$last_name", + "full_name": "$full_name", + "max_duration": "$max_duration" + } + } + }, + { + "name": "hunter_email_verifier", + "description": "Verify if an email is deliverable. Returns status (valid/invalid/accept_all/webmail/disposable/unknown) + score 0-100 + smtp_check + mx_records.", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "Email to verify." } + }, + "required": ["email"] + }, + "endpointMapping": { + "method": "GET", + "path": "/email-verifier", + "queryParams": { "email": "$email" } + } + }, + { + "name": "hunter_email_count", + "description": "Count emails Hunter has indexed for a domain. FREE (no credits). Useful to estimate before a domain_search.", + "parameters": { + "type": "object", + "properties": { + "domain": { "type": "string", "description": "Domain." }, + "company": { "type": "string", "description": "Or company name." }, + "type": { "type": "string", "description": "personal or generic." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/email-count", + "queryParams": { + "domain": "$domain", + "company": "$company", + "type": "$type" + } + } + }, + { + "name": "hunter_combined_enrichment", + "description": "Combined person + company enrichment in one call. Provide an email — returns the person's name, role, employment + the company's industry, employees, location, technologies.", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "Email to enrich." } + }, + "required": ["email"] + }, + "endpointMapping": { + "method": "GET", + "path": "/combined/find", + "queryParams": { "email": "$email" } + } + }, + { + "name": "hunter_company_enrichment", + "description": "Enrich a company by domain. Returns industry, employee count, address, country, technologies, social profiles, description.", + "parameters": { + "type": "object", + "properties": { + "domain": { "type": "string", "description": "Company domain." } + }, + "required": ["domain"] + }, + "endpointMapping": { + "method": "GET", + "path": "/companies/find", + "queryParams": { "domain": "$domain" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/hunter.live.spec.ts b/packages/backend/src/adapters/intl/hunter.live.spec.ts new file mode 100644 index 0000000..a7dff5a --- /dev/null +++ b/packages/backend/src/adapters/intl/hunter.live.spec.ts @@ -0,0 +1,11 @@ +import * as adapter from './hunter.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: Record }; +}; +describe('hunter adapter — static spec conformance', () => { + it('api.hunter.io/v2', () => expect(a.connector.baseUrl).toBe('https://api.hunter.io/v2')); + it('QUERY_AUTH with api_key', () => { + expect(a.connector.authType).toBe('QUERY_AUTH'); + expect(a.connector.authConfig.api_key).toBe('{{HUNTER_API_KEY}}'); + }); +}); diff --git a/packages/backend/src/adapters/intl/mapbox.json b/packages/backend/src/adapters/intl/mapbox.json new file mode 100644 index 0000000..5c658c8 --- /dev/null +++ b/packages/backend/src/adapters/intl/mapbox.json @@ -0,0 +1,213 @@ +{ + "slug": "mapbox", + "name": "Mapbox", + "description": "Drive Mapbox APIs (geocoding, directions, isochrones, matrix, tilequery) from any AI agent. 8 tools, access-token query auth.", + "instructions": "This connector uses Mapbox's various REST APIs (docs.mapbox.com/api/).\n\n**Setup**:\n1. Sign in to https://account.mapbox.com → **Access tokens → Create a token**.\n2. Pick scopes: usually `styles:read` + `fonts:read` + `geocoding` + `directions` + `matrix` + `isochrone` + `tilequery`.\n3. Copy the token (starts with `pk.`). Set `MAPBOX_ACCESS_TOKEN`.\n\n**Authentication**: query-string `?access_token=...`.\n\n**Coordinate convention** (Mapbox-specific): **longitude FIRST**, then latitude. The order is `lng,lat`, NOT `lat,lng` like Google Maps. Trip up on this and your results land in the wrong country.\n\n**Geocoding v6 (forward + reverse)** is now the default — uses `/search/geocode/v6/forward` and `/reverse`. v5 still works at `/geocoding/v5/mapbox.places/...` but is in maintenance.\n\n**Directions profiles**: driving, walking, cycling, driving-traffic (live traffic, premium tier).\n\n**Cost**: all Mapbox APIs have a generous free tier (50k geocoding/month, 100k directions/month). Beyond that, paid per request — check pricing page. Each API call counts as 1 'API hit'.\n\n**Out of scope here**: tile rendering (use Mapbox GL JS), uploads, custom datasets, navigation SDK.", + "region": "intl", + "category": "maps", + "icon": "mapbox", + "docsUrl": "https://docs.mapbox.com/api/", + "requiredEnvVars": ["MAPBOX_ACCESS_TOKEN"], + "connector": { + "name": "Mapbox API", + "type": "REST", + "baseUrl": "https://api.mapbox.com", + "authType": "QUERY_AUTH", + "authConfig": { + "access_token": "{{MAPBOX_ACCESS_TOKEN}}" + } + }, + "tools": [ + { + "name": "mapbox_geocode_forward", + "description": "Forward geocoding (address text → coordinates). Returns ranked candidates with [longitude, latitude], place_name, address components.", + "parameters": { + "type": "object", + "properties": { + "q": { "type": "string", "description": "Free-text address or place name." }, + "limit": { "type": "integer", "description": "Max results (1-10, default 5)." }, + "country": { "type": "string", "description": "Comma-separated ISO 3166-1 alpha-2 codes to limit results (e.g. 'us,ca')." }, + "proximity": { "type": "string", "description": "'lng,lat' coordinate to bias results toward (e.g. user's current location)." }, + "language": { "type": "string", "description": "IETF tag for the language of results (en, de, fr, es, ja, zh, etc.)." }, + "types": { "type": "string", "description": "Comma-separated place types: country,region,postcode,district,place,locality,neighborhood,address,poi." } + }, + "required": ["q"] + }, + "endpointMapping": { + "method": "GET", + "path": "/search/geocode/v6/forward", + "queryParams": { + "q": "$q", + "limit": "$limit", + "country": "$country", + "proximity": "$proximity", + "language": "$language", + "types": "$types" + } + } + }, + { + "name": "mapbox_geocode_reverse", + "description": "Reverse geocoding (coordinates → address). Pass longitude + latitude.", + "parameters": { + "type": "object", + "properties": { + "longitude": { "type": "number", "description": "Longitude in decimal degrees." }, + "latitude": { "type": "number", "description": "Latitude in decimal degrees." }, + "types": { "type": "string", "description": "Place types to consider." }, + "language": { "type": "string", "description": "Result language tag." }, + "limit": { "type": "integer", "description": "Max results (default 1)." } + }, + "required": ["longitude", "latitude"] + }, + "endpointMapping": { + "method": "GET", + "path": "/search/geocode/v6/reverse", + "queryParams": { + "longitude": "$longitude", + "latitude": "$latitude", + "types": "$types", + "language": "$language", + "limit": "$limit" + } + } + }, + { + "name": "mapbox_directions", + "description": "Compute driving / walking / cycling directions between waypoints. Returns route geometry, duration (seconds), distance (meters), turn-by-turn steps.", + "parameters": { + "type": "object", + "properties": { + "profile": { "type": "string", "description": "driving, walking, cycling, driving-traffic." }, + "coordinates": { "type": "string", "description": "Semicolon-separated 'lng,lat' waypoints (2-25). E.g. '13.4050,52.5200;9.9937,53.5511'." }, + "alternatives": { "type": "boolean", "description": "Return alternative routes too." }, + "geometries": { "type": "string", "description": "geojson, polyline, polyline6 (default polyline)." }, + "overview": { "type": "string", "description": "full, simplified, false." }, + "steps": { "type": "boolean", "description": "Include turn-by-turn steps." }, + "language": { "type": "string", "description": "Voice/text language." } + }, + "required": ["profile", "coordinates"] + }, + "endpointMapping": { + "method": "GET", + "path": "/directions/v5/mapbox/{profile}/{coordinates}", + "queryParams": { + "alternatives": "$alternatives", + "geometries": "$geometries", + "overview": "$overview", + "steps": "$steps", + "language": "$language" + } + } + }, + { + "name": "mapbox_isochrone", + "description": "Get the area reachable within N minutes from a starting point (an 'isochrone' — like 'how far can I drive in 15 min?'). Returns a GeoJSON polygon.", + "parameters": { + "type": "object", + "properties": { + "profile": { "type": "string", "description": "driving, walking, cycling." }, + "coordinates": { "type": "string", "description": "Origin 'lng,lat'." }, + "contours_minutes": { "type": "string", "description": "Comma-separated minute thresholds (e.g. '5,10,15'). Max 4 contours, max 60 min each." }, + "contours_meters": { "type": "string", "description": "Or distance-based: '500,1000,1500' meters (alternative to minutes)." }, + "polygons": { "type": "boolean", "description": "If true return polygons (default), if false return linestrings." }, + "generalize": { "type": "number", "description": "Smoothing in meters (positive number)." } + }, + "required": ["profile", "coordinates"] + }, + "endpointMapping": { + "method": "GET", + "path": "/isochrone/v1/mapbox/{profile}/{coordinates}", + "queryParams": { + "contours_minutes": "$contours_minutes", + "contours_meters": "$contours_meters", + "polygons": "$polygons", + "generalize": "$generalize" + } + } + }, + { + "name": "mapbox_matrix", + "description": "Compute travel-time and distance MATRIX between many origins and destinations (e.g. 5 warehouses × 100 customers = 500 cells). Returns durations[][] in seconds and distances[][] in meters.", + "parameters": { + "type": "object", + "properties": { + "profile": { "type": "string", "description": "driving, walking, cycling, driving-traffic." }, + "coordinates": { "type": "string", "description": "Semicolon-separated 'lng,lat' (max 25 for driving/walking/cycling, 10 for driving-traffic)." }, + "sources": { "type": "string", "description": "Comma-separated 0-based indices into coordinates to use as sources. 'all' to use all." }, + "destinations": { "type": "string", "description": "Same for destinations." }, + "annotations": { "type": "string", "description": "duration, distance, or 'duration,distance' (default duration only)." } + }, + "required": ["profile", "coordinates"] + }, + "endpointMapping": { + "method": "GET", + "path": "/directions-matrix/v1/mapbox/{profile}/{coordinates}", + "queryParams": { + "sources": "$sources", + "destinations": "$destinations", + "annotations": "$annotations" + } + } + }, + { + "name": "mapbox_static_image", + "description": "Generate a static PNG/JPG map of a location with optional pin overlays. Returns the image binary URL.", + "parameters": { + "type": "object", + "properties": { + "username": { "type": "string", "description": "Mapbox style username (typically 'mapbox' for built-in styles)." }, + "style_id": { "type": "string", "description": "Style ID, e.g. 'streets-v12', 'outdoors-v12', 'satellite-v9'." }, + "overlay": { "type": "string", "description": "Optional pin overlay, e.g. 'pin-s-a+9ed4bd(-87.0186,32.4055)'." }, + "lng": { "type": "number", "description": "Center longitude." }, + "lat": { "type": "number", "description": "Center latitude." }, + "zoom": { "type": "number", "description": "0-22." }, + "width": { "type": "integer", "description": "Pixels (max 1280)." }, + "height": { "type": "integer", "description": "Pixels (max 1280)." } + }, + "required": ["username", "style_id", "lng", "lat", "zoom", "width", "height"] + }, + "endpointMapping": { + "method": "GET", + "path": "/styles/v1/{username}/{style_id}/static/{overlay}{lng},{lat},{zoom}/{width}x{height}" + } + }, + { + "name": "mapbox_tilequery", + "description": "Query features in a vector tileset at a specific point — e.g. find which neighborhood a coordinate falls in.", + "parameters": { + "type": "object", + "properties": { + "tileset_id": { "type": "string", "description": "Tileset ID, e.g. 'mapbox.mapbox-streets-v8'." }, + "lng": { "type": "number", "description": "Longitude." }, + "lat": { "type": "number", "description": "Latitude." }, + "radius": { "type": "integer", "description": "Search radius in meters (default 0)." }, + "layers": { "type": "string", "description": "Comma-separated layer names to query." }, + "limit": { "type": "integer", "description": "Max features (default 5, max 50)." } + }, + "required": ["tileset_id", "lng", "lat"] + }, + "endpointMapping": { + "method": "GET", + "path": "/v4/{tileset_id}/tilequery/{lng},{lat}.json", + "queryParams": { + "radius": "$radius", + "layers": "$layers", + "limit": "$limit" + } + } + }, + { + "name": "mapbox_list_styles", + "description": "List custom map styles owned by your account. Use to discover style IDs for the static-image endpoint.", + "parameters": { + "type": "object", + "properties": { + "username": { "type": "string", "description": "Mapbox account username." } + }, + "required": ["username"] + }, + "endpointMapping": { "method": "GET", "path": "/styles/v1/{username}" } + } + ] +} diff --git a/packages/backend/src/adapters/intl/mapbox.live.spec.ts b/packages/backend/src/adapters/intl/mapbox.live.spec.ts new file mode 100644 index 0000000..d33b4c9 --- /dev/null +++ b/packages/backend/src/adapters/intl/mapbox.live.spec.ts @@ -0,0 +1,11 @@ +import * as adapter from './mapbox.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: Record }; +}; +describe('mapbox adapter — static spec conformance', () => { + it('api.mapbox.com', () => expect(a.connector.baseUrl).toBe('https://api.mapbox.com')); + it('QUERY_AUTH with access_token', () => { + expect(a.connector.authType).toBe('QUERY_AUTH'); + expect(a.connector.authConfig.access_token).toBe('{{MAPBOX_ACCESS_TOKEN}}'); + }); +}); diff --git a/packages/backend/src/adapters/intl/mintlify.json b/packages/backend/src/adapters/intl/mintlify.json new file mode 100644 index 0000000..db179b2 --- /dev/null +++ b/packages/backend/src/adapters/intl/mintlify.json @@ -0,0 +1,102 @@ +{ + "slug": "mintlify", + "name": "Mintlify", + "description": "Trigger Mintlify documentation updates, query analytics, and manage assistant queries via the Mintlify API. 5 tools, Bearer-token auth.", + "instructions": "This connector uses the Mintlify API v1 (mintlify.com/docs/api-reference).\n\n**Setup**:\n1. Sign in to https://dashboard.mintlify.com → **Settings → API Keys → Create API key**.\n2. Copy the key (starts with `mint_`). Set `MINTLIFY_API_KEY`.\n\n**Authentication**: `Authorization: Bearer ${MINTLIFY_API_KEY}`.\n\n**Mintlify's API is scoped**: most write operations (trigger update, manage content) are not exposed because Mintlify expects content to live in a git repo and update on push. The API focuses on:\n - Triggering re-deploys\n - Querying assistant (chat) interactions\n - Pulling analytics / search data\n\n**Out of scope here**: page/content CRUD (manage via the git repo), team management, OAuth user actions.", + "region": "intl", + "category": "publishing", + "icon": "mintlify", + "docsUrl": "https://mintlify.com/docs/api-reference", + "requiredEnvVars": ["MINTLIFY_API_KEY"], + "connector": { + "name": "Mintlify v1", + "type": "REST", + "baseUrl": "https://api.mintlify.com/v1", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{MINTLIFY_API_KEY}}" + } + }, + "tools": [ + { + "name": "mintlify_trigger_update", + "description": "Trigger a re-deploy of the documentation site (forces Mintlify to pull from the connected git repo and re-build). Use after a content change pipeline.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "POST", "path": "/project/update" } + }, + { + "name": "mintlify_get_project_info", + "description": "Get project metadata: name, repo, deployment status, custom domain.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/project" } + }, + { + "name": "mintlify_list_assistant_conversations", + "description": "List user conversations with the Mintlify AI assistant on your docs site. Returns query text, timestamp, helpful flag.", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Max per page." }, + "page": { "type": "integer", "description": "Page number." }, + "start_date": { "type": "string", "description": "ISO 8601." }, + "end_date": { "type": "string", "description": "ISO 8601." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/assistant/conversations", + "queryParams": { + "limit": "$limit", + "page": "$page", + "start_date": "$start_date", + "end_date": "$end_date" + } + } + }, + { + "name": "mintlify_list_search_queries", + "description": "List user search queries on the docs site (analytics). Useful to identify missing or confusing content.", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Max per page." }, + "page": { "type": "integer", "description": "Page number." }, + "start_date": { "type": "string", "description": "ISO 8601." }, + "end_date": { "type": "string", "description": "ISO 8601." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/search/queries", + "queryParams": { + "limit": "$limit", + "page": "$page", + "start_date": "$start_date", + "end_date": "$end_date" + } + } + }, + { + "name": "mintlify_get_analytics", + "description": "Retrieve site analytics aggregations (pageviews, unique visitors, top pages) for a date range.", + "parameters": { + "type": "object", + "properties": { + "start_date": { "type": "string", "description": "ISO 8601 start." }, + "end_date": { "type": "string", "description": "ISO 8601 end." }, + "granularity": { "type": "string", "description": "hour, day, week, month." } + }, + "required": ["start_date", "end_date"] + }, + "endpointMapping": { + "method": "GET", + "path": "/analytics", + "queryParams": { + "start_date": "$start_date", + "end_date": "$end_date", + "granularity": "$granularity" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/mintlify.live.spec.ts b/packages/backend/src/adapters/intl/mintlify.live.spec.ts new file mode 100644 index 0000000..5aab1fd --- /dev/null +++ b/packages/backend/src/adapters/intl/mintlify.live.spec.ts @@ -0,0 +1,8 @@ +import * as adapter from './mintlify.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: Record }; +}; +describe('mintlify adapter — static spec conformance', () => { + it('api.mintlify.com/v1', () => expect(a.connector.baseUrl).toBe('https://api.mintlify.com/v1')); + it('Bearer auth', () => expect(a.connector.authType).toBe('BEARER_TOKEN')); +}); diff --git a/packages/backend/src/adapters/intl/neverbounce.json b/packages/backend/src/adapters/intl/neverbounce.json new file mode 100644 index 0000000..c097e5e --- /dev/null +++ b/packages/backend/src/adapters/intl/neverbounce.json @@ -0,0 +1,145 @@ +{ + "slug": "neverbounce", + "name": "NeverBounce", + "description": "Validate single emails or run bulk-list verification jobs via NeverBounce. 6 tools, query-string API-key auth.", + "instructions": "This connector uses the NeverBounce API v4.2 (developers.neverbounce.com).\n\n**Setup**:\n1. Sign in to https://app.neverbounce.com → **Apps → New API Key**.\n2. Copy the key. Set `NEVERBOUNCE_API_KEY`.\n\n**Authentication**: query-string `?key=...`. NeverBounce also supports a custom `Auth` header but query-string is the primary.\n\n**Single vs Bulk**:\n - `neverbounce_single_check`: synchronous single-email verification. ~1 credit. Returns result (valid/invalid/disposable/catchall/unknown) + flags + suggested_correction.\n - `neverbounce_create_job`: async bulk verification of a list (up to 1M emails). Returns job_id; poll status with `neverbounce_job_status` until status='complete'; then `neverbounce_job_results` to fetch.\n\n**Result codes**: 0=valid, 1=invalid, 2=disposable, 3=catchall, 4=unknown. Use ONLY 0 (valid) for safe sends if you're conservative.\n\n**Bulk job flow**: 1) create_job with input.payload=array of {email,name?,id?} OR input.payload=URL to CSV. 2) parse the job into ready state via `parse_async` OR `auto_parse=true` flag at create. 3) start the job via `start`. 4) poll `status`. 5) fetch `results`. The adapter abstracts steps 2-3 via the `auto_parse + auto_start` flags on create.\n\n**Cost**: per credit per email checked. Bulk jobs are slightly cheaper than singles.\n\n**Out of scope here**: account billing, custom integrations dashboard.", + "region": "intl", + "category": "enrichment", + "icon": "neverbounce", + "docsUrl": "https://developers.neverbounce.com/", + "requiredEnvVars": ["NEVERBOUNCE_API_KEY"], + "connector": { + "name": "NeverBounce v4.2", + "type": "REST", + "baseUrl": "https://api.neverbounce.com/v4.2", + "authType": "QUERY_AUTH", + "authConfig": { + "key": "{{NEVERBOUNCE_API_KEY}}" + } + }, + "tools": [ + { + "name": "neverbounce_account_info", + "description": "Return account credit balance and details. Use to check remaining credits before bulk operations.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/account/info" } + }, + { + "name": "neverbounce_single_check", + "description": "Verify a single email synchronously. Returns result (valid/invalid/disposable/catchall/unknown), flags (has_dns_mx, smtp_connectable, etc.), suggested_correction (typo fix).", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "Email to verify." }, + "address_info": { "type": "integer", "description": "1 = also return parsed address parts." }, + "credits_info": { "type": "integer", "description": "1 = also return remaining credits in response." }, + "timeout": { "type": "integer", "description": "Max seconds for the check (3-30, default 10)." } + }, + "required": ["email"] + }, + "endpointMapping": { + "method": "GET", + "path": "/single/check", + "queryParams": { + "email": "$email", + "address_info": "$address_info", + "credits_info": "$credits_info", + "timeout": "$timeout" + } + } + }, + { + "name": "neverbounce_create_job", + "description": "Create a bulk verification job. input.payload is either an array of {email,name?,id?} (max 1k inline) OR a URL to a CSV file. Set auto_parse=true and run_sample=false + auto_start=true to skip the manual parse+start steps.", + "parameters": { + "type": "object", + "properties": { + "input_location": { "type": "string", "description": "'supplied' (inline array) or 'remote_url' (CSV at the URL)." }, + "input": { "description": "If input_location=supplied: array of {email,name?,id?} objects. If remote_url: the URL string." }, + "filename": { "type": "string", "description": "Optional friendly name for the job." }, + "auto_parse": { "type": "boolean", "description": "If true, NeverBounce auto-parses the input — saves a separate /jobs/parse call." }, + "auto_start": { "type": "boolean", "description": "If true, the job runs immediately after parse." }, + "run_sample": { "type": "boolean", "description": "If true, NeverBounce runs the first ~1000 first as a sample to estimate credit usage." }, + "allow_manual_review": { "type": "boolean", "description": "If true, low-confidence items go to manual review (slower, higher accuracy)." } + }, + "required": ["input_location", "input"] + }, + "endpointMapping": { + "method": "POST", + "path": "/jobs/create", + "bodyMapping": { + "input_location": "$input_location", + "input": "$input", + "filename": "$filename", + "auto_parse": "$auto_parse", + "auto_start": "$auto_start", + "run_sample": "$run_sample", + "allow_manual_review": "$allow_manual_review" + } + } + }, + { + "name": "neverbounce_job_status", + "description": "Poll the status of a bulk verification job. Returns job_status (queued/waiting/working/complete/failed/parsing/manual_review), progress (0-100), total/processed/valid/invalid counts.", + "parameters": { + "type": "object", + "properties": { + "job_id": { "type": "integer", "description": "Job ID returned by create_job." } + }, + "required": ["job_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/jobs/status", + "queryParams": { "job_id": "$job_id" } + } + }, + { + "name": "neverbounce_job_results", + "description": "Fetch verification results for a complete job, paginated. Each result row has email, name, id (your input ID), verification (result code), flags[].", + "parameters": { + "type": "object", + "properties": { + "job_id": { "type": "integer", "description": "Job ID." }, + "page": { "type": "integer", "description": "1-based page." }, + "items_per_page": { "type": "integer", "description": "Default 10, max 1000." } + }, + "required": ["job_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/jobs/results", + "queryParams": { + "job_id": "$job_id", + "page": "$page", + "items_per_page": "$items_per_page" + } + } + }, + { + "name": "neverbounce_jobs_search", + "description": "Search past jobs by status, name, date. Useful to find historical verification runs.", + "parameters": { + "type": "object", + "properties": { + "job_id": { "type": "integer", "description": "Exact job ID." }, + "filename": { "type": "string", "description": "Filename substring." }, + "job_status": { "type": "string", "description": "queued, waiting, working, complete, failed, parsing." }, + "page": { "type": "integer", "description": "Page." }, + "items_per_page": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/jobs/search", + "queryParams": { + "job_id": "$job_id", + "filename": "$filename", + "job_status": "$job_status", + "page": "$page", + "items_per_page": "$items_per_page" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/neverbounce.live.spec.ts b/packages/backend/src/adapters/intl/neverbounce.live.spec.ts new file mode 100644 index 0000000..061831f --- /dev/null +++ b/packages/backend/src/adapters/intl/neverbounce.live.spec.ts @@ -0,0 +1,11 @@ +import * as adapter from './neverbounce.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: Record }; +}; +describe('neverbounce adapter — static spec conformance', () => { + it('api.neverbounce.com/v4.2', () => expect(a.connector.baseUrl).toBe('https://api.neverbounce.com/v4.2')); + it('QUERY_AUTH with key', () => { + expect(a.connector.authType).toBe('QUERY_AUTH'); + expect(a.connector.authConfig.key).toBe('{{NEVERBOUNCE_API_KEY}}'); + }); +}); diff --git a/packages/backend/src/adapters/intl/nominatim.json b/packages/backend/src/adapters/intl/nominatim.json new file mode 100644 index 0000000..e51a76a --- /dev/null +++ b/packages/backend/src/adapters/intl/nominatim.json @@ -0,0 +1,128 @@ +{ + "slug": "nominatim", + "name": "Nominatim (OpenStreetMap)", + "description": "Free, no-auth geocoding using OpenStreetMap's Nominatim service. Forward + reverse geocoding + place lookup + search. 4 tools.", + "instructions": "This connector uses Nominatim — OpenStreetMap's free geocoding service (nominatim.org).\n\n**Setup**: NO API KEY REQUIRED. Just install the connector. The base URL is `https://nominatim.openstreetmap.org`.\n\n**HOWEVER — usage policy you MUST respect** (otherwise you'll get banned):\n 1. **Max 1 request per second** per IP. The connector does NOT throttle for you — your agent must self-limit. For bulk usage, run a self-hosted Nominatim or use a paid Nominatim host.\n 2. **User-Agent header REQUIRED** — every request must identify you. The connector pins `User-Agent: AnythingMCP (https://anythingmcp.com)` automatically.\n 3. **No heavy bulk geocoding** — if you need >100 requests/hour, switch to MapTiler / OpenCage / Mapbox / Google.\n 4. **Cache results** for at least 24h — avoid re-geocoding the same address.\n\n**Coordinate convention**: latitude FIRST then longitude (unlike Mapbox). Format is `lat,lon` in some endpoints, `lat=X&lon=Y` query params in others.\n\n**Languages**: pass `accept-language` query param (e.g. `de`, `en`, `fr`) to localize place names.\n\n**Result format**: `format=json` (compact), `format=jsonv2` (richer), `format=geojson` (GeoJSON Feature). Default `xml` — always pass `format=json` for our tools.\n\n**Address importance ranking**: results include `importance` (0-1, Wikipedia-derived). High importance = well-known place; low = obscure.\n\n**Out of scope here**: address_lookup batch (use repeated single calls), search by category beyond default, photon (separate but related service).", + "region": "intl", + "category": "maps", + "icon": "openstreetmap", + "docsUrl": "https://nominatim.org/release-docs/develop/api/Overview/", + "requiredEnvVars": [], + "connector": { + "name": "Nominatim", + "type": "REST", + "baseUrl": "https://nominatim.openstreetmap.org", + "authType": "NONE" + }, + "tools": [ + { + "name": "nominatim_search", + "description": "Forward geocode (free-text address → coordinates + structured address). Be polite: max 1 req/sec, cache results, set realistic User-Agent.", + "parameters": { + "type": "object", + "properties": { + "q": { "type": "string", "description": "Free-text query, e.g. 'Brandenburg Gate, Berlin'." }, + "format": { "type": "string", "description": "json (compact), jsonv2 (more fields), geojson. Default json." }, + "addressdetails": { "type": "integer", "description": "1 = include parsed address components." }, + "extratags": { "type": "integer", "description": "1 = include extra OSM tags." }, + "namedetails": { "type": "integer", "description": "1 = include localized name variants." }, + "limit": { "type": "integer", "description": "Max results (default 10, max 50)." }, + "countrycodes": { "type": "string", "description": "Comma-separated ISO 3166-1 alpha-2 codes (e.g. 'de,fr')." }, + "viewbox": { "type": "string", "description": "Bounding box 'left,top,right,bottom' (lng,lat) to bias." }, + "bounded": { "type": "integer", "description": "If 1 with viewbox, restrict to box." }, + "accept_language": { "type": "string", "description": "Localized output language (e.g. 'de', 'en-US')." }, + "dedupe": { "type": "integer", "description": "1 = deduplicate (default)." } + }, + "required": ["q"] + }, + "endpointMapping": { + "method": "GET", + "path": "/search", + "queryParams": { + "q": "$q", + "format": "$format", + "addressdetails": "$addressdetails", + "extratags": "$extratags", + "namedetails": "$namedetails", + "limit": "$limit", + "countrycodes": "$countrycodes", + "viewbox": "$viewbox", + "bounded": "$bounded", + "accept-language": "$accept_language", + "dedupe": "$dedupe" + }, + "headers": { "User-Agent": "AnythingMCP (https://anythingmcp.com)" } + } + }, + { + "name": "nominatim_reverse", + "description": "Reverse geocode (coordinates → address). Returns display_name + address components.", + "parameters": { + "type": "object", + "properties": { + "lat": { "type": "number", "description": "Latitude in decimal degrees." }, + "lon": { "type": "number", "description": "Longitude in decimal degrees." }, + "format": { "type": "string", "description": "json (compact), jsonv2, geojson." }, + "zoom": { "type": "integer", "description": "Address detail level: 3 (country) → 18 (building). Default 18." }, + "addressdetails": { "type": "integer", "description": "1 = parsed address parts." }, + "accept_language": { "type": "string", "description": "Output language." } + }, + "required": ["lat", "lon"] + }, + "endpointMapping": { + "method": "GET", + "path": "/reverse", + "queryParams": { + "lat": "$lat", + "lon": "$lon", + "format": "$format", + "zoom": "$zoom", + "addressdetails": "$addressdetails", + "accept-language": "$accept_language" + }, + "headers": { "User-Agent": "AnythingMCP (https://anythingmcp.com)" } + } + }, + { + "name": "nominatim_lookup", + "description": "Look up known OSM IDs (N1234, W5678, R9012 for nodes/ways/relations). Comma-separated up to 50.", + "parameters": { + "type": "object", + "properties": { + "osm_ids": { "type": "string", "description": "Comma-separated OSM IDs prefixed with N/W/R, e.g. 'R146656,W104393803,N240109189'." }, + "format": { "type": "string", "description": "json, jsonv2, geojson." }, + "addressdetails": { "type": "integer", "description": "1 = address parts." }, + "accept_language": { "type": "string", "description": "Output language." } + }, + "required": ["osm_ids"] + }, + "endpointMapping": { + "method": "GET", + "path": "/lookup", + "queryParams": { + "osm_ids": "$osm_ids", + "format": "$format", + "addressdetails": "$addressdetails", + "accept-language": "$accept_language" + }, + "headers": { "User-Agent": "AnythingMCP (https://anythingmcp.com)" } + } + }, + { + "name": "nominatim_status", + "description": "Service status check. Returns 'OK' if Nominatim is up. Useful health check.", + "parameters": { + "type": "object", + "properties": { + "format": { "type": "string", "description": "text or json." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/status", + "queryParams": { "format": "$format" }, + "headers": { "User-Agent": "AnythingMCP (https://anythingmcp.com)" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/nominatim.live.spec.ts b/packages/backend/src/adapters/intl/nominatim.live.spec.ts new file mode 100644 index 0000000..516b252 --- /dev/null +++ b/packages/backend/src/adapters/intl/nominatim.live.spec.ts @@ -0,0 +1,14 @@ +import * as adapter from './nominatim.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string }; + tools: Array<{ name: string; endpointMapping: { method: string; path: string; headers?: Record } }>; +}; +describe('nominatim adapter — static spec conformance', () => { + it('nominatim.openstreetmap.org', () => expect(a.connector.baseUrl).toBe('https://nominatim.openstreetmap.org')); + it('NONE auth (public, no key)', () => expect(a.connector.authType).toBe('NONE')); + it('every tool pins User-Agent (Nominatim policy requirement)', () => { + for (const t of a.tools) { + expect(t.endpointMapping.headers?.['User-Agent']).toMatch(/AnythingMCP/); + } + }); +}); From 42a822316c85319bd68cf2b432f8a508c2691915 Mon Sep 17 00:00:00 2001 From: Matteo Date: Wed, 20 May 2026 12:47:38 +0200 Subject: [PATCH 09/19] connectors: add Tally, SurveyMonkey, Freshdesk, Mollie, PandaDoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 6 — mixed batch covering forms, support, payments, e-signature. - Tally: 6 tools — read workspaces/forms/submissions, get form questions. Modern Typeform alternative with generous free tier. - SurveyMonkey v3: 8 tools — surveys, responses bulk, collectors, contact lists. Heavy on read for response analysis. - Freshdesk v2: 16 tools — tickets CRUD with reply/note distinction, Lucene-like search, contacts/companies/agents/groups, ticket fields for custom-field composition. BASIC_AUTH with key as username + 'X' as password (Freshdesk convention). Subdomain-templated base URL. - Mollie v2: 14 tools — payments, refunds, customers, subscriptions with mandates, mandates revoke, methods. Documents the {currency,value:'10.00'} amount shape (value is STRING with exact decimals — sending number errors). - PandaDoc v1: 10 tools — create document from template, send for signature, poll status, download PDF, templates with role/token discovery. Custom 'API-Key' prefix (NOT Bearer). Catalog: 75 adapters (35/81 greenfield batch done). --- packages/backend/src/adapters/catalog.ts | 10 + .../backend/src/adapters/intl/freshdesk.json | 371 ++++++++++++++++++ .../src/adapters/intl/freshdesk.live.spec.ts | 14 + .../backend/src/adapters/intl/mollie.json | 314 +++++++++++++++ .../src/adapters/intl/mollie.live.spec.ts | 6 + .../backend/src/adapters/intl/pandadoc.json | 236 +++++++++++ .../src/adapters/intl/pandadoc.live.spec.ts | 10 + .../src/adapters/intl/surveymonkey.json | 157 ++++++++ .../adapters/intl/surveymonkey.live.spec.ts | 6 + packages/backend/src/adapters/intl/tally.json | 117 ++++++ .../src/adapters/intl/tally.live.spec.ts | 6 + 11 files changed, 1247 insertions(+) create mode 100644 packages/backend/src/adapters/intl/freshdesk.json create mode 100644 packages/backend/src/adapters/intl/freshdesk.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/mollie.json create mode 100644 packages/backend/src/adapters/intl/mollie.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/pandadoc.json create mode 100644 packages/backend/src/adapters/intl/pandadoc.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/surveymonkey.json create mode 100644 packages/backend/src/adapters/intl/surveymonkey.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/tally.json create mode 100644 packages/backend/src/adapters/intl/tally.live.spec.ts diff --git a/packages/backend/src/adapters/catalog.ts b/packages/backend/src/adapters/catalog.ts index 6de9628..6dabe03 100644 --- a/packages/backend/src/adapters/catalog.ts +++ b/packages/backend/src/adapters/catalog.ts @@ -42,6 +42,7 @@ import * as coda from './intl/coda.json'; import * as convertkit from './intl/convertkit.json'; import * as copper from './intl/copper.json'; import * as discordBot from './intl/discord-bot.json'; +import * as freshdesk from './intl/freshdesk.json'; import * as hunter from './intl/hunter.json'; import * as klaviyo from './intl/klaviyo.json'; import * as lemlist from './intl/lemlist.json'; @@ -50,13 +51,17 @@ import * as loops from './intl/loops.json'; import * as mailchimp from './intl/mailchimp.json'; import * as mapbox from './intl/mapbox.json'; import * as mintlify from './intl/mintlify.json'; +import * as mollie from './intl/mollie.json'; import * as neverbounce from './intl/neverbounce.json'; import * as nominatim from './intl/nominatim.json'; import * as outreach from './intl/outreach.json'; +import * as pandadoc from './intl/pandadoc.json'; import * as pipedrive from './intl/pipedrive.json'; import * as salesloft from './intl/salesloft.json'; import * as sendgrid from './intl/sendgrid.json'; import * as sorare from './intl/sorare.json'; +import * as surveymonkey from './intl/surveymonkey.json'; +import * as tally from './intl/tally.json'; import * as telegramBot from './intl/telegram-bot.json'; import * as todoist from './intl/todoist.json'; import * as trello from './intl/trello.json'; @@ -184,6 +189,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ convertkit as unknown as AdapterDefinition, copper as unknown as AdapterDefinition, discordBot as unknown as AdapterDefinition, + freshdesk as unknown as AdapterDefinition, hunter as unknown as AdapterDefinition, klaviyo as unknown as AdapterDefinition, lemlist as unknown as AdapterDefinition, @@ -192,13 +198,17 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ mailchimp as unknown as AdapterDefinition, mapbox as unknown as AdapterDefinition, mintlify as unknown as AdapterDefinition, + mollie as unknown as AdapterDefinition, neverbounce as unknown as AdapterDefinition, nominatim as unknown as AdapterDefinition, outreach as unknown as AdapterDefinition, + pandadoc as unknown as AdapterDefinition, pipedrive as unknown as AdapterDefinition, salesloft as unknown as AdapterDefinition, sendgrid as unknown as AdapterDefinition, sorare as unknown as AdapterDefinition, + surveymonkey as unknown as AdapterDefinition, + tally as unknown as AdapterDefinition, telegramBot as unknown as AdapterDefinition, todoist as unknown as AdapterDefinition, trello as unknown as AdapterDefinition, diff --git a/packages/backend/src/adapters/intl/freshdesk.json b/packages/backend/src/adapters/intl/freshdesk.json new file mode 100644 index 0000000..1c499f5 --- /dev/null +++ b/packages/backend/src/adapters/intl/freshdesk.json @@ -0,0 +1,371 @@ +{ + "slug": "freshdesk", + "name": "Freshdesk", + "description": "Drive Freshdesk (customer support helpdesk) from any AI agent: tickets, contacts, companies, agents, conversations. 16 tools, API-key Basic-auth, per-domain base URL.", + "instructions": "This connector uses the Freshdesk REST API v2 (developers.freshdesk.com).\n\n**Setup**:\n1. Sign in to Freshdesk → top-right avatar → **Profile Settings → Your API Key** (right sidebar).\n2. Copy the key. Set:\n - `FRESHDESK_DOMAIN` = your account subdomain (e.g. `acme` if URL is `acme.freshdesk.com`)\n - `FRESHDESK_API_KEY` = the API key\n\n**Authentication**: HTTP Basic with username=API_KEY, password='X' (any non-empty). The adapter handles this via BASIC_AUTH with username=`{{FRESHDESK_API_KEY}}` and password=`X`.\n\n**Subdomain in base URL**: `https://{{FRESHDESK_DOMAIN}}.freshdesk.com/api/v2`. Wrong subdomain → 404 with vendor's branded 'Page not found'.\n\n**Ticket status integer codes**: 2=Open, 3=Pending, 4=Resolved, 5=Closed. Custom statuses get integer IDs assigned in Admin UI.\n\n**Priority codes**: 1=Low, 2=Medium, 3=High, 4=Urgent.\n\n**Source codes**: 1=Email, 2=Portal, 3=Phone, 4=Forum, 5=Twitter, 6=Facebook, 7=Chat, 8=MobiHelp, 9=FeedbackWidget, 10=OutboundEmail, 11=Ecommerce.\n\n**Updating ticket reply vs note**: replies (visible to requester) go to `/tickets/{id}/reply` with body. Internal notes go to `/tickets/{id}/notes` with body and private=true. The adapter exposes both.\n\n**Pagination**: `?page=N&per_page=M` (max 100). Total count via `?include=stats` or via response Link header.\n\n**Rate limits**: ~1000 req/hour per plan baseline (varies). On 429 honor Retry-After.\n\n**Out of scope here**: automations/workflows, SLA policies, solution articles (Freshdesk Solutions has its own API), satisfaction surveys, Freshchat/Freshcaller integrations (separate Freshworks products).", + "region": "intl", + "category": "support", + "icon": "freshdesk", + "docsUrl": "https://developers.freshdesk.com/api/", + "requiredEnvVars": ["FRESHDESK_DOMAIN", "FRESHDESK_API_KEY"], + "connector": { + "name": "Freshdesk v2", + "type": "REST", + "baseUrl": "https://{{FRESHDESK_DOMAIN}}.freshdesk.com/api/v2", + "authType": "BASIC_AUTH", + "authConfig": { + "username": "{{FRESHDESK_API_KEY}}", + "password": "X" + } + }, + "tools": [ + { + "name": "freshdesk_list_tickets", + "description": "List tickets. Use filter for new_and_my_open / watching / spam / deleted, or use freshdesk_search_tickets for query-based.", + "parameters": { + "type": "object", + "properties": { + "filter": { "type": "string", "description": "new_and_my_open, watching, spam, deleted." }, + "requester_id": { "type": "integer", "description": "Filter by requester." }, + "email": { "type": "string", "description": "Filter by requester email." }, + "company_id": { "type": "integer", "description": "Filter by company." }, + "updated_since": { "type": "string", "description": "ISO 8601 — tickets updated after." }, + "order_by": { "type": "string", "description": "created_at, updated_at, due_by, status, priority." }, + "order_type": { "type": "string", "description": "asc or desc." }, + "include": { "type": "string", "description": "Side-load: requester, stats, description, company." }, + "page": { "type": "integer", "description": "Page." }, + "per_page": { "type": "integer", "description": "Per page (max 100)." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/tickets", + "queryParams": { + "filter": "$filter", + "requester_id": "$requester_id", + "email": "$email", + "company_id": "$company_id", + "updated_since": "$updated_since", + "order_by": "$order_by", + "order_type": "$order_type", + "include": "$include", + "page": "$page", + "per_page": "$per_page" + } + } + }, + { + "name": "freshdesk_search_tickets", + "description": "Search tickets with a query language. Example: 'status:2 AND priority:4'. Supports field operators like status, priority, agent_id, group_id, tag, type, source, created_at, due_by, fr_due_by.", + "parameters": { + "type": "object", + "properties": { + "query": { "type": "string", "description": "Search query in Freshdesk syntax. Wrap whole query in single quotes." }, + "page": { "type": "integer", "description": "Page (1-10 only — search is paginated to first 1000 results)." } + }, + "required": ["query"] + }, + "endpointMapping": { + "method": "GET", + "path": "/search/tickets", + "queryParams": { "query": "$query", "page": "$page" } + } + }, + { + "name": "freshdesk_get_ticket", + "description": "Fetch a single ticket. Use include for conversations / requester / company / stats.", + "parameters": { + "type": "object", + "properties": { + "ticketId": { "type": "integer", "description": "Ticket ID." }, + "include": { "type": "string", "description": "Side-load: conversations, requester, company, stats." } + }, + "required": ["ticketId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/tickets/{ticketId}", + "queryParams": { "include": "$include" } + } + }, + { + "name": "freshdesk_create_ticket", + "description": "Create a ticket. Required: subject + description + (email OR requester_id) + status + priority. Status/priority are integers (see instructions).", + "parameters": { + "type": "object", + "properties": { + "subject": { "type": "string", "description": "Ticket subject." }, + "description": { "type": "string", "description": "HTML body." }, + "email": { "type": "string", "description": "Requester email (auto-creates contact if missing)." }, + "requester_id": { "type": "integer", "description": "Existing contact ID (alternative to email)." }, + "status": { "type": "integer", "description": "2=Open, 3=Pending, 4=Resolved, 5=Closed." }, + "priority": { "type": "integer", "description": "1=Low, 2=Medium, 3=High, 4=Urgent." }, + "source": { "type": "integer", "description": "Source code (see instructions)." }, + "type": { "type": "string", "description": "Ticket type (e.g. 'Question', 'Incident')." }, + "responder_id": { "type": "integer", "description": "Assigned agent ID." }, + "group_id": { "type": "integer", "description": "Assigned group ID." }, + "tags": { "type": "array", "description": "Array of tag strings." }, + "cc_emails": { "type": "array", "description": "CC email addresses." }, + "custom_fields": { "type": "object", "description": "Custom fields keyed by field name." } + }, + "required": ["subject", "description", "status", "priority"] + }, + "endpointMapping": { + "method": "POST", + "path": "/tickets", + "bodyMapping": { + "subject": "$subject", + "description": "$description", + "email": "$email", + "requester_id": "$requester_id", + "status": "$status", + "priority": "$priority", + "source": "$source", + "type": "$type", + "responder_id": "$responder_id", + "group_id": "$group_id", + "tags": "$tags", + "cc_emails": "$cc_emails", + "custom_fields": "$custom_fields" + } + } + }, + { + "name": "freshdesk_update_ticket", + "description": "Update a ticket. Common: change status (close=5), reassign (responder_id), update priority.", + "parameters": { + "type": "object", + "properties": { + "ticketId": { "type": "integer", "description": "Ticket ID." }, + "subject": { "type": "string", "description": "New subject." }, + "status": { "type": "integer", "description": "2/3/4/5." }, + "priority": { "type": "integer", "description": "1-4." }, + "responder_id": { "type": "integer", "description": "Reassign agent." }, + "group_id": { "type": "integer", "description": "Reassign group." }, + "tags": { "type": "array", "description": "Replace tag set." }, + "custom_fields": { "type": "object", "description": "Custom field updates." } + }, + "required": ["ticketId"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/tickets/{ticketId}", + "bodyMapping": { + "subject": "$subject", + "status": "$status", + "priority": "$priority", + "responder_id": "$responder_id", + "group_id": "$group_id", + "tags": "$tags", + "custom_fields": "$custom_fields" + } + } + }, + { + "name": "freshdesk_delete_ticket", + "description": "Move ticket to trash (recoverable from UI within retention window).", + "parameters": { + "type": "object", + "properties": { + "ticketId": { "type": "integer", "description": "Ticket ID." } + }, + "required": ["ticketId"] + }, + "endpointMapping": { "method": "DELETE", "path": "/tickets/{ticketId}" } + }, + { + "name": "freshdesk_reply_to_ticket", + "description": "Send a public reply to the requester on a ticket.", + "parameters": { + "type": "object", + "properties": { + "ticketId": { "type": "integer", "description": "Ticket ID." }, + "body": { "type": "string", "description": "HTML reply body." }, + "from_email": { "type": "string", "description": "Override From email (must be a verified product email)." }, + "user_id": { "type": "integer", "description": "Agent ID sending the reply." }, + "cc_emails": { "type": "array", "description": "CC list." }, + "bcc_emails": { "type": "array", "description": "BCC list." } + }, + "required": ["ticketId", "body"] + }, + "endpointMapping": { + "method": "POST", + "path": "/tickets/{ticketId}/reply", + "bodyMapping": { + "body": "$body", + "from_email": "$from_email", + "user_id": "$user_id", + "cc_emails": "$cc_emails", + "bcc_emails": "$bcc_emails" + } + } + }, + { + "name": "freshdesk_add_note_to_ticket", + "description": "Add an internal note (private=true) or a public note to a ticket. Notes don't email the requester (unlike replies).", + "parameters": { + "type": "object", + "properties": { + "ticketId": { "type": "integer", "description": "Ticket ID." }, + "body": { "type": "string", "description": "HTML note body." }, + "private": { "type": "boolean", "description": "true (default) = internal only, false = public." }, + "user_id": { "type": "integer", "description": "Agent ID." }, + "notify_emails": { "type": "array", "description": "Agent emails to notify." } + }, + "required": ["ticketId", "body"] + }, + "endpointMapping": { + "method": "POST", + "path": "/tickets/{ticketId}/notes", + "bodyMapping": { + "body": "$body", + "private": "$private", + "user_id": "$user_id", + "notify_emails": "$notify_emails" + } + } + }, + { + "name": "freshdesk_list_ticket_conversations", + "description": "List all conversations (replies + notes) on a ticket, in chronological order.", + "parameters": { + "type": "object", + "properties": { + "ticketId": { "type": "integer", "description": "Ticket ID." }, + "page": { "type": "integer", "description": "Page." }, + "per_page": { "type": "integer", "description": "Per page (max 30)." } + }, + "required": ["ticketId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/tickets/{ticketId}/conversations", + "queryParams": { "page": "$page", "per_page": "$per_page" } + } + }, + { + "name": "freshdesk_list_contacts", + "description": "List contacts (requesters/end-users).", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "Filter by email." }, + "company_id": { "type": "integer", "description": "Filter by company." }, + "updated_since": { "type": "string", "description": "ISO 8601." }, + "page": { "type": "integer", "description": "Page." }, + "per_page": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/contacts", + "queryParams": { + "email": "$email", + "company_id": "$company_id", + "_updated_since": "$updated_since", + "page": "$page", + "per_page": "$per_page" + } + } + }, + { + "name": "freshdesk_get_contact", + "description": "Fetch a single contact.", + "parameters": { + "type": "object", + "properties": { + "contactId": { "type": "integer", "description": "Contact ID." } + }, + "required": ["contactId"] + }, + "endpointMapping": { "method": "GET", "path": "/contacts/{contactId}" } + }, + { + "name": "freshdesk_create_contact", + "description": "Create a contact. Required: name + (email OR phone OR mobile OR twitter_id OR unique_external_id).", + "parameters": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Full name." }, + "email": { "type": "string", "description": "Email." }, + "phone": { "type": "string", "description": "Phone." }, + "mobile": { "type": "string", "description": "Mobile phone." }, + "twitter_id": { "type": "string", "description": "Twitter handle." }, + "company_id": { "type": "integer", "description": "Linked company." }, + "job_title": { "type": "string", "description": "Title." }, + "address": { "type": "string", "description": "Free-text address." }, + "tags": { "type": "array", "description": "Tag strings." }, + "custom_fields": { "type": "object", "description": "Custom fields." } + }, + "required": ["name"] + }, + "endpointMapping": { + "method": "POST", + "path": "/contacts", + "bodyMapping": { + "name": "$name", + "email": "$email", + "phone": "$phone", + "mobile": "$mobile", + "twitter_id": "$twitter_id", + "company_id": "$company_id", + "job_title": "$job_title", + "address": "$address", + "tags": "$tags", + "custom_fields": "$custom_fields" + } + } + }, + { + "name": "freshdesk_list_companies", + "description": "List companies (organizations of contacts).", + "parameters": { + "type": "object", + "properties": { + "page": { "type": "integer", "description": "Page." }, + "per_page": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/companies", + "queryParams": { "page": "$page", "per_page": "$per_page" } + } + }, + { + "name": "freshdesk_list_agents", + "description": "List agents (support staff).", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "Filter by email." }, + "mobile": { "type": "string", "description": "Filter by mobile." }, + "page": { "type": "integer", "description": "Page." }, + "per_page": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/agents", + "queryParams": { + "email": "$email", + "mobile": "$mobile", + "page": "$page", + "per_page": "$per_page" + } + } + }, + { + "name": "freshdesk_list_groups", + "description": "List agent groups (for group_id assignment).", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/groups" } + }, + { + "name": "freshdesk_list_ticket_fields", + "description": "List ticket fields including custom ones with their names and types. Required to compose custom_fields objects.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/ticket_fields" } + } + ] +} diff --git a/packages/backend/src/adapters/intl/freshdesk.live.spec.ts b/packages/backend/src/adapters/intl/freshdesk.live.spec.ts new file mode 100644 index 0000000..40cfd39 --- /dev/null +++ b/packages/backend/src/adapters/intl/freshdesk.live.spec.ts @@ -0,0 +1,14 @@ +import * as adapter from './freshdesk.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: Record }; +}; +describe('freshdesk adapter — static spec conformance', () => { + it('domain-templated base URL', () => { + expect(a.connector.baseUrl).toBe('https://{{FRESHDESK_DOMAIN}}.freshdesk.com/api/v2'); + }); + it('BASIC_AUTH with key as username and X as password', () => { + expect(a.connector.authType).toBe('BASIC_AUTH'); + expect(a.connector.authConfig.username).toBe('{{FRESHDESK_API_KEY}}'); + expect(a.connector.authConfig.password).toBe('X'); + }); +}); diff --git a/packages/backend/src/adapters/intl/mollie.json b/packages/backend/src/adapters/intl/mollie.json new file mode 100644 index 0000000..c18490c --- /dev/null +++ b/packages/backend/src/adapters/intl/mollie.json @@ -0,0 +1,314 @@ +{ + "slug": "mollie", + "name": "Mollie", + "description": "Drive Mollie (EU-friendly payments) from any AI agent: payments, refunds, customers, subscriptions, mandates, methods. 14 tools, Bearer auth.", + "instructions": "This connector uses the Mollie API v2 (docs.mollie.com/reference).\n\n**Setup**:\n1. Sign in to https://my.mollie.com → **Developers → API keys**.\n2. Use a **test key** (`test_...`) for development, **live key** (`live_...`) for production.\n3. Set `MOLLIE_API_KEY` accordingly. (Mollie tells you which environment via the key prefix.)\n\n**Authentication**: `Authorization: Bearer ${MOLLIE_API_KEY}`.\n\n**Amount format**: every monetary amount is `{currency: 'EUR', value: '10.00'}` — value is a STRING with exactly 2 decimals (or whatever the currency requires). Sending `value: 10` (number) errors.\n\n**Payment lifecycle**: created → open → pending → authorized → paid (success) | canceled | expired | failed.\n\n**Profile and mode**: every payment belongs to a profile (website). On Mollie you can have multiple profiles per account. The `profileId` is implicit (uses the default profile of the key); for multi-profile accounts pass it explicitly.\n\n**Subscriptions and mandates**: subscriptions need a mandate (a pre-authorized customer payment method). Workflow: create customer → first one-off payment with `sequenceType=first` → use the resulting mandate to create the subscription.\n\n**Webhooks** out of scope (Mollie pushes payment.* events to a URL you host).\n\n**Pagination**: cursor-based via `from=` + `limit` (max 250). Response has `_links.next.href` for next page.\n\n**Rate limits**: 250 req per 5 sec per IP. On 429 honor headers.\n\n**Out of scope here**: settlements, invoices, profile management, organizations, partners (Mollie Connect), permissions.", + "region": "intl", + "category": "payments", + "icon": "mollie", + "docsUrl": "https://docs.mollie.com/reference/", + "requiredEnvVars": ["MOLLIE_API_KEY"], + "connector": { + "name": "Mollie v2", + "type": "REST", + "baseUrl": "https://api.mollie.com/v2", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{MOLLIE_API_KEY}}" + } + }, + "tools": [ + { + "name": "mollie_list_methods", + "description": "List payment methods enabled for the profile. Each method has id (e.g. 'ideal', 'creditcard', 'paypal', 'klarna', 'banktransfer'), description, minimumAmount, maximumAmount, image, status.", + "parameters": { + "type": "object", + "properties": { + "sequenceType": { "type": "string", "description": "first, recurring, oneoff." }, + "locale": { "type": "string", "description": "Locale for descriptions, e.g. 'en_US', 'nl_NL'." }, + "amount_currency": { "type": "string", "description": "Optional: only methods accepting this currency." }, + "amount_value": { "type": "string", "description": "Optional: only methods accepting this value (as decimal string)." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/methods", + "queryParams": { + "sequenceType": "$sequenceType", + "locale": "$locale", + "amount[currency]": "$amount_currency", + "amount[value]": "$amount_value" + } + } + }, + { + "name": "mollie_create_payment", + "description": "Create a payment. amount is {currency, value}. Always include `redirectUrl` (where the customer returns post-payment) and `webhookUrl` (where Mollie pushes status updates).", + "parameters": { + "type": "object", + "properties": { + "amount": { "type": "object", "description": "{currency:'EUR', value:'10.00'} — value MUST be a string with 2 decimals." }, + "description": { "type": "string", "description": "Shown to customer on Mollie checkout (≤255 chars)." }, + "redirectUrl": { "type": "string", "description": "URL the customer is sent back to after payment." }, + "webhookUrl": { "type": "string", "description": "Your endpoint that receives payment-status notifications." }, + "method": { "type": "string", "description": "Restrict to a method (or array of methods). Omit to show Mollie's full checkout." }, + "metadata": { "type": "object", "description": "Free-form key/value (≤1KB) — passes through unchanged." }, + "locale": { "type": "string", "description": "Mollie checkout language: en_US, nl_NL, de_DE, fr_FR, es_ES, it_IT, pt_PT, pl_PL, ..." }, + "customerId": { "type": "string", "description": "Mollie customer ID (for recurring or saved methods)." }, + "sequenceType": { "type": "string", "description": "first (creates mandate for future recurring), recurring (uses existing mandate), oneoff (default)." }, + "mandateId": { "type": "string", "description": "Mandate ID for recurring." } + }, + "required": ["amount", "description"] + }, + "endpointMapping": { + "method": "POST", + "path": "/payments", + "bodyMapping": { + "amount": "$amount", + "description": "$description", + "redirectUrl": "$redirectUrl", + "webhookUrl": "$webhookUrl", + "method": "$method", + "metadata": "$metadata", + "locale": "$locale", + "customerId": "$customerId", + "sequenceType": "$sequenceType", + "mandateId": "$mandateId" + } + } + }, + { + "name": "mollie_get_payment", + "description": "Fetch a payment by ID. Returns status + _links.checkout (URL to send customer to) for pending payments.", + "parameters": { + "type": "object", + "properties": { + "paymentId": { "type": "string", "description": "Payment ID (starts with 'tr_')." } + }, + "required": ["paymentId"] + }, + "endpointMapping": { "method": "GET", "path": "/payments/{paymentId}" } + }, + { + "name": "mollie_list_payments", + "description": "List payments. Filter by profile, customer, status, mandate. Cursor-paginated.", + "parameters": { + "type": "object", + "properties": { + "from": { "type": "string", "description": "Cursor: payment ID to start from." }, + "limit": { "type": "integer", "description": "Max per page (default 50, max 250)." }, + "profileId": { "type": "string", "description": "Filter by profile." }, + "testmode": { "type": "boolean", "description": "true for test, false for live (defaults to key's environment)." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/payments", + "queryParams": { + "from": "$from", + "limit": "$limit", + "profileId": "$profileId", + "testmode": "$testmode" + } + } + }, + { + "name": "mollie_cancel_payment", + "description": "Cancel a payment in 'open' state (before customer pays). After 'paid' use refund instead.", + "parameters": { + "type": "object", + "properties": { + "paymentId": { "type": "string", "description": "Payment ID." } + }, + "required": ["paymentId"] + }, + "endpointMapping": { "method": "DELETE", "path": "/payments/{paymentId}" } + }, + { + "name": "mollie_create_refund", + "description": "Refund a paid payment. Full refund: omit amount. Partial: pass amount {currency,value}.", + "parameters": { + "type": "object", + "properties": { + "paymentId": { "type": "string", "description": "Payment ID to refund." }, + "amount": { "type": "object", "description": "{currency,value} — omit for full refund." }, + "description": { "type": "string", "description": "Refund description (shown on bank statement when possible)." }, + "metadata": { "type": "object", "description": "Free-form metadata." } + }, + "required": ["paymentId"] + }, + "endpointMapping": { + "method": "POST", + "path": "/payments/{paymentId}/refunds", + "bodyMapping": { + "amount": "$amount", + "description": "$description", + "metadata": "$metadata" + } + } + }, + { + "name": "mollie_list_refunds", + "description": "List refunds (across all payments by default; pass paymentId for per-payment).", + "parameters": { + "type": "object", + "properties": { + "paymentId": { "type": "string", "description": "Optional: limit to one payment." }, + "from": { "type": "string", "description": "Cursor." }, + "limit": { "type": "integer", "description": "Max per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/refunds", + "queryParams": { + "paymentId": "$paymentId", + "from": "$from", + "limit": "$limit" + } + } + }, + { + "name": "mollie_create_customer", + "description": "Create a Mollie customer (required for recurring subscriptions, optional for one-off payments). Customer is the durable identity across payments.", + "parameters": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Customer name." }, + "email": { "type": "string", "description": "Email." }, + "locale": { "type": "string", "description": "Default locale for emails/checkout." }, + "metadata": { "type": "object", "description": "Free-form metadata." } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/customers", + "bodyMapping": { + "name": "$name", + "email": "$email", + "locale": "$locale", + "metadata": "$metadata" + } + } + }, + { + "name": "mollie_list_customers", + "description": "List customers. Cursor-paginated.", + "parameters": { + "type": "object", + "properties": { + "from": { "type": "string", "description": "Cursor." }, + "limit": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/customers", + "queryParams": { "from": "$from", "limit": "$limit" } + } + }, + { + "name": "mollie_create_subscription", + "description": "Create a subscription for a customer. Required: customerId + amount + interval. interval examples: '1 month', '14 days', '1 year'. Requires the customer to have an active mandate (created via a `sequenceType=first` payment).", + "parameters": { + "type": "object", + "properties": { + "customerId": { "type": "string", "description": "Customer ID (starts with 'cst_')." }, + "amount": { "type": "object", "description": "{currency, value}." }, + "times": { "type": "integer", "description": "Optional max charges (omit for unlimited)." }, + "interval": { "type": "string", "description": "Charge cadence: '1 month', '14 days', '1 year'." }, + "startDate": { "type": "string", "description": "YYYY-MM-DD when first charge happens." }, + "description": { "type": "string", "description": "Shown on each invoice." }, + "method": { "type": "string", "description": "Restrict to a method (e.g. 'directdebit')." }, + "mandateId": { "type": "string", "description": "Mandate to use (defaults to customer's most recent)." }, + "webhookUrl": { "type": "string", "description": "Endpoint for subscription event notifications." }, + "metadata": { "type": "object", "description": "Free-form metadata." } + }, + "required": ["customerId", "amount", "interval", "description"] + }, + "endpointMapping": { + "method": "POST", + "path": "/customers/{customerId}/subscriptions", + "bodyMapping": { + "amount": "$amount", + "times": "$times", + "interval": "$interval", + "startDate": "$startDate", + "description": "$description", + "method": "$method", + "mandateId": "$mandateId", + "webhookUrl": "$webhookUrl", + "metadata": "$metadata" + } + } + }, + { + "name": "mollie_cancel_subscription", + "description": "Cancel a subscription (stops future charges immediately).", + "parameters": { + "type": "object", + "properties": { + "customerId": { "type": "string", "description": "Customer ID." }, + "subscriptionId": { "type": "string", "description": "Subscription ID (starts with 'sub_')." } + }, + "required": ["customerId", "subscriptionId"] + }, + "endpointMapping": { + "method": "DELETE", + "path": "/customers/{customerId}/subscriptions/{subscriptionId}" + } + }, + { + "name": "mollie_list_subscriptions", + "description": "List subscriptions for a customer.", + "parameters": { + "type": "object", + "properties": { + "customerId": { "type": "string", "description": "Customer ID." }, + "from": { "type": "string", "description": "Cursor." }, + "limit": { "type": "integer", "description": "Per page." } + }, + "required": ["customerId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/customers/{customerId}/subscriptions", + "queryParams": { "from": "$from", "limit": "$limit" } + } + }, + { + "name": "mollie_list_mandates", + "description": "List mandates (pre-authorized payment methods) on a customer.", + "parameters": { + "type": "object", + "properties": { + "customerId": { "type": "string", "description": "Customer ID." }, + "from": { "type": "string", "description": "Cursor." }, + "limit": { "type": "integer", "description": "Per page." } + }, + "required": ["customerId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/customers/{customerId}/mandates", + "queryParams": { "from": "$from", "limit": "$limit" } + } + }, + { + "name": "mollie_revoke_mandate", + "description": "Revoke a mandate (stops future recurring charges using it).", + "parameters": { + "type": "object", + "properties": { + "customerId": { "type": "string", "description": "Customer ID." }, + "mandateId": { "type": "string", "description": "Mandate ID (mdt_*)." } + }, + "required": ["customerId", "mandateId"] + }, + "endpointMapping": { + "method": "DELETE", + "path": "/customers/{customerId}/mandates/{mandateId}" + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/mollie.live.spec.ts b/packages/backend/src/adapters/intl/mollie.live.spec.ts new file mode 100644 index 0000000..b2e7052 --- /dev/null +++ b/packages/backend/src/adapters/intl/mollie.live.spec.ts @@ -0,0 +1,6 @@ +import * as adapter from './mollie.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string; authConfig: Record } }; +describe('mollie adapter — static spec conformance', () => { + it('api.mollie.com/v2', () => expect(a.connector.baseUrl).toBe('https://api.mollie.com/v2')); + it('Bearer auth', () => expect(a.connector.authType).toBe('BEARER_TOKEN')); +}); diff --git a/packages/backend/src/adapters/intl/pandadoc.json b/packages/backend/src/adapters/intl/pandadoc.json new file mode 100644 index 0000000..6c5337b --- /dev/null +++ b/packages/backend/src/adapters/intl/pandadoc.json @@ -0,0 +1,236 @@ +{ + "slug": "pandadoc", + "name": "PandaDoc", + "description": "Drive PandaDoc (proposals + e-signature) from any AI agent: documents, templates, contacts, recipients, sending for signature, status tracking. 10 tools, API-key auth.", + "instructions": "This connector uses the PandaDoc API v1 (developers.pandadoc.com).\n\n**Setup**:\n1. Sign in to https://app.pandadoc.com → top-right avatar → **API → API Key**.\n2. Use the **Production API Key** (or **Sandbox** for testing). The key format is ``.\n3. Set `PANDADOC_API_KEY`.\n\n**Authentication**: `Authorization: API-Key ${PANDADOC_API_KEY}` — literal `API-Key ` prefix, NOT `Bearer`. Adapter handles via API_KEY profile.\n\n**Document workflow**:\n 1. Create document from template (`pandadoc_create_document`) — fills template variables with `tokens` and pricing tables, sets recipients.\n 2. Wait for document.status = 'document.draft' (initial state).\n 3. Send for signature (`pandadoc_send_document`).\n 4. Recipients receive emails; poll `pandadoc_get_document_status` to track signing.\n 5. When all signed, status = 'document.completed' and you can download via `pandadoc_download_document`.\n\n**Templates**: build the visual template in PandaDoc UI; access via `pandadoc_list_templates` to get template_id. Templates have `tokens` (placeholders), `fields` (form inputs filled by recipients), `pricing_tables`, and roles (named recipient slots like 'Client', 'Vendor').\n\n**Status values**: document.draft, document.sent, document.viewed, document.waiting_approval, document.approved, document.rejected, document.completed, document.expired, document.declined, document.voided.\n\n**Pagination**: `?page=N&count=M` (max 100). Headers include X-Total-Count.\n\n**Rate limits**: 100 req/min per API key. On 429 back off.\n\n**Out of scope here**: webhooks management, OAuth (for multi-tenant apps), in-app branding, payment forms config.", + "region": "intl", + "category": "e-signature", + "icon": "pandadoc", + "docsUrl": "https://developers.pandadoc.com/reference/about", + "requiredEnvVars": ["PANDADOC_API_KEY"], + "connector": { + "name": "PandaDoc v1", + "type": "REST", + "baseUrl": "https://api.pandadoc.com/public/v1", + "authType": "API_KEY", + "authConfig": { + "headerName": "Authorization", + "apiKey": "API-Key {{PANDADOC_API_KEY}}" + } + }, + "tools": [ + { + "name": "pandadoc_list_documents", + "description": "List documents in the workspace, with filters.", + "parameters": { + "type": "object", + "properties": { + "page": { "type": "integer", "description": "Page (1-based)." }, + "count": { "type": "integer", "description": "Per page (max 100)." }, + "q": { "type": "string", "description": "Substring search on document name." }, + "status": { "type": "integer", "description": "Filter by numeric status: 0=draft, 1=sent, 2=completed, 3=viewed, 4=waiting_approval, 5=approved, 6=rejected, 7=expired, 8=declined, 9=voided, 11=waiting_pay, 12=paid, 13=external_review." }, + "tag": { "type": "string", "description": "Filter by tag." }, + "folder_uuid": { "type": "string", "description": "Filter by folder." }, + "template_id": { "type": "string", "description": "Only docs created from this template." }, + "completed_from": { "type": "string", "description": "ISO 8601 date." }, + "completed_to": { "type": "string", "description": "ISO 8601." }, + "created_from": { "type": "string", "description": "ISO 8601." }, + "created_to": { "type": "string", "description": "ISO 8601." }, + "modified_from": { "type": "string", "description": "ISO 8601." }, + "modified_to": { "type": "string", "description": "ISO 8601." }, + "order_by": { "type": "string", "description": "name, date_created, date_modified. Prefix with - for desc." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/documents", + "queryParams": { + "page": "$page", + "count": "$count", + "q": "$q", + "status": "$status", + "tag": "$tag", + "folder_uuid": "$folder_uuid", + "template_id": "$template_id", + "completed_from": "$completed_from", + "completed_to": "$completed_to", + "created_from": "$created_from", + "created_to": "$created_to", + "modified_from": "$modified_from", + "modified_to": "$modified_to", + "order_by": "$order_by" + } + } + }, + { + "name": "pandadoc_create_document", + "description": "Create a document from a template. Required: name + template_uuid + recipients (with email + first_name + last_name + role). Tokens fill template placeholders. Pricing tables for line-item docs.", + "parameters": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Document name." }, + "template_uuid": { "type": "string", "description": "Template UUID to instantiate." }, + "recipients": { "type": "array", "description": "[{email, first_name, last_name, role:'Client'|'Vendor'|..., signing_order?:N}]. role matches a template role." }, + "tokens": { "type": "array", "description": "[{name:'Client.Company', value:'ACME'}] — replaces {{Client.Company}} in template content." }, + "fields": { "type": "object", "description": "{field_name:{value, role?}} — fills form fields in template." }, + "pricing_tables": { "type": "array", "description": "Pricing tables data (template-defined)." }, + "metadata": { "type": "object", "description": "Free-form metadata (string K/V)." }, + "tags": { "type": "array", "description": "Tag strings." }, + "folder_uuid": { "type": "string", "description": "Put in folder." } + }, + "required": ["name", "template_uuid", "recipients"] + }, + "endpointMapping": { + "method": "POST", + "path": "/documents", + "bodyMapping": { + "name": "$name", + "template_uuid": "$template_uuid", + "recipients": "$recipients", + "tokens": "$tokens", + "fields": "$fields", + "pricing_tables": "$pricing_tables", + "metadata": "$metadata", + "tags": "$tags", + "folder_uuid": "$folder_uuid" + } + } + }, + { + "name": "pandadoc_get_document_status", + "description": "Quick status check for a document. Returns id, status, expiration_date.", + "parameters": { + "type": "object", + "properties": { + "documentId": { "type": "string", "description": "Document UUID." } + }, + "required": ["documentId"] + }, + "endpointMapping": { "method": "GET", "path": "/documents/{documentId}" } + }, + { + "name": "pandadoc_get_document_details", + "description": "Fetch document full details — recipients with signing status, tokens, fields, pricing data, attachments.", + "parameters": { + "type": "object", + "properties": { + "documentId": { "type": "string", "description": "Document UUID." } + }, + "required": ["documentId"] + }, + "endpointMapping": { "method": "GET", "path": "/documents/{documentId}/details" } + }, + { + "name": "pandadoc_send_document", + "description": "Send the document for signing/review. Required: documentId. Optional: subject + message (the email content recipients receive).", + "parameters": { + "type": "object", + "properties": { + "documentId": { "type": "string", "description": "Document UUID." }, + "subject": { "type": "string", "description": "Email subject." }, + "message": { "type": "string", "description": "Email body." }, + "silent": { "type": "boolean", "description": "If true, skip recipient notification emails (you handle communication yourself)." } + }, + "required": ["documentId"] + }, + "endpointMapping": { + "method": "POST", + "path": "/documents/{documentId}/send", + "bodyMapping": { + "subject": "$subject", + "message": "$message", + "silent": "$silent" + } + } + }, + { + "name": "pandadoc_download_document", + "description": "Download the finalized PDF of a document. Returns binary PDF (the engine returns it as the response body — handle it as a file).", + "parameters": { + "type": "object", + "properties": { + "documentId": { "type": "string", "description": "Document UUID." }, + "watermark_text": { "type": "string", "description": "Optional watermark." }, + "watermark_color": { "type": "string", "description": "Hex color for watermark." }, + "watermark_font_size": { "type": "integer", "description": "Watermark font size." }, + "watermark_opacity": { "type": "number", "description": "0.0-1.0." } + }, + "required": ["documentId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/documents/{documentId}/download", + "queryParams": { + "watermark_text": "$watermark_text", + "watermark_color": "$watermark_color", + "watermark_font_size": "$watermark_font_size", + "watermark_opacity": "$watermark_opacity" + } + } + }, + { + "name": "pandadoc_delete_document", + "description": "Delete (move to trash) a document.", + "parameters": { + "type": "object", + "properties": { + "documentId": { "type": "string", "description": "Document UUID." } + }, + "required": ["documentId"] + }, + "endpointMapping": { "method": "DELETE", "path": "/documents/{documentId}" } + }, + { + "name": "pandadoc_list_templates", + "description": "List templates available in the workspace. Required to know which template_uuid to pass to create_document.", + "parameters": { + "type": "object", + "properties": { + "page": { "type": "integer", "description": "Page." }, + "count": { "type": "integer", "description": "Per page." }, + "q": { "type": "string", "description": "Name substring." }, + "tag": { "type": "string", "description": "Tag filter." }, + "folder_uuid": { "type": "string", "description": "Folder filter." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/templates", + "queryParams": { + "page": "$page", + "count": "$count", + "q": "$q", + "tag": "$tag", + "folder_uuid": "$folder_uuid" + } + } + }, + { + "name": "pandadoc_get_template_details", + "description": "Fetch a template's full structure: roles[], tokens[], fields[], pricing_tables, content. Required to know what tokens/fields to fill when creating a document.", + "parameters": { + "type": "object", + "properties": { + "templateId": { "type": "string", "description": "Template UUID." } + }, + "required": ["templateId"] + }, + "endpointMapping": { "method": "GET", "path": "/templates/{templateId}/details" } + }, + { + "name": "pandadoc_list_contacts", + "description": "List contacts in the workspace (saved recipients).", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "Filter by exact email." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/contacts", + "queryParams": { "email": "$email" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/pandadoc.live.spec.ts b/packages/backend/src/adapters/intl/pandadoc.live.spec.ts new file mode 100644 index 0000000..e0dbfb9 --- /dev/null +++ b/packages/backend/src/adapters/intl/pandadoc.live.spec.ts @@ -0,0 +1,10 @@ +import * as adapter from './pandadoc.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string; authConfig: Record } }; +describe('pandadoc adapter — static spec conformance', () => { + it('api.pandadoc.com/public/v1', () => expect(a.connector.baseUrl).toBe('https://api.pandadoc.com/public/v1')); + it('API-Key prefix (NOT Bearer)', () => { + expect(a.connector.authType).toBe('API_KEY'); + expect(a.connector.authConfig.headerName).toBe('Authorization'); + expect(a.connector.authConfig.apiKey).toBe('API-Key {{PANDADOC_API_KEY}}'); + }); +}); diff --git a/packages/backend/src/adapters/intl/surveymonkey.json b/packages/backend/src/adapters/intl/surveymonkey.json new file mode 100644 index 0000000..3a140a7 --- /dev/null +++ b/packages/backend/src/adapters/intl/surveymonkey.json @@ -0,0 +1,157 @@ +{ + "slug": "surveymonkey", + "name": "SurveyMonkey", + "description": "Drive SurveyMonkey from any AI agent: surveys, responses, collectors, contacts. 8 tools, OAuth2 Bearer auth.", + "instructions": "This connector uses the SurveyMonkey API v3 (api.surveymonkey.com/v3).\n\n**Setup**:\n1. Register at https://developer.surveymonkey.com → **Add new app** → Private app.\n2. Pick scopes: Surveys (Read), Responses (Read), Collectors (Read+Write), Contacts (Read+Write), Webhooks (Read+Write).\n3. Run the OAuth2 flow to obtain an access token. Set `SURVEYMONKEY_ACCESS_TOKEN`.\n4. SurveyMonkey tokens are LONG-LIVED — but can be revoked by user via app dashboard.\n\n**Authentication**: `Authorization: Bearer ${SURVEYMONKEY_ACCESS_TOKEN}`.\n\n**Survey vs Collector vs Response**:\n - Survey: the question design.\n - Collector: a distribution channel (weblink/email/popup/embed) attached to a survey.\n - Response: a submission to a collector.\n\n**Quirky response shape**: each response has `pages[]` → `questions[]` → `answers[]`. Answer shape varies by question type (single_choice, multiple_choice, text, etc.). Cross-reference with `surveymonkey_get_survey_details` to map answer choice IDs to display text.\n\n**Pagination**: `?page=N&per_page=M` (max 1000 for some endpoints).\n\n**Rate limits**: 500 req/day on Free tier, scales with paid plans. 60 req/min throttle. On 429 honor Retry-After.\n\n**Out of scope here**: survey CRUD (use the SurveyMonkey designer), library themes, A/B testing, sentiment analysis built-ins.", + "region": "intl", + "category": "forms", + "icon": "surveymonkey", + "docsUrl": "https://developer.surveymonkey.com/api/v3/", + "requiredEnvVars": ["SURVEYMONKEY_ACCESS_TOKEN"], + "connector": { + "name": "SurveyMonkey v3", + "type": "REST", + "baseUrl": "https://api.surveymonkey.com/v3", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{SURVEYMONKEY_ACCESS_TOKEN}}" + } + }, + "tools": [ + { + "name": "surveymonkey_get_user", + "description": "Return the user the token belongs to.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/users/me" } + }, + { + "name": "surveymonkey_list_surveys", + "description": "List surveys. Each returns id, title, nickname, href, question_count, response_count, date_created, date_modified.", + "parameters": { + "type": "object", + "properties": { + "page": { "type": "integer", "description": "Page." }, + "per_page": { "type": "integer", "description": "Per page (max 1000)." }, + "sort_by": { "type": "string", "description": "title or date_modified." }, + "sort_order": { "type": "string", "description": "ASC or DESC." }, + "start_modified_at": { "type": "string", "description": "ISO 8601 filter." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/surveys", + "queryParams": { + "page": "$page", + "per_page": "$per_page", + "sort_by": "$sort_by", + "sort_order": "$sort_order", + "start_modified_at": "$start_modified_at" + } + } + }, + { + "name": "surveymonkey_get_survey_details", + "description": "Fetch a survey's full structure with pages[].questions[].answers (choice IDs → display text). Required to interpret responses.", + "parameters": { + "type": "object", + "properties": { + "surveyId": { "type": "string", "description": "Survey ID." } + }, + "required": ["surveyId"] + }, + "endpointMapping": { "method": "GET", "path": "/surveys/{surveyId}/details" } + }, + { + "name": "surveymonkey_list_survey_responses", + "description": "List responses to a survey (bulk endpoint includes full answer data). Filter by date range, collector, status.", + "parameters": { + "type": "object", + "properties": { + "surveyId": { "type": "string", "description": "Survey ID." }, + "page": { "type": "integer", "description": "Page." }, + "per_page": { "type": "integer", "description": "Per page (max 100 for bulk)." }, + "sort_by": { "type": "string", "description": "date_modified." }, + "sort_order": { "type": "string", "description": "ASC or DESC." }, + "start_created_at": { "type": "string", "description": "ISO 8601." }, + "end_created_at": { "type": "string", "description": "ISO 8601." }, + "collector_ids": { "type": "string", "description": "Comma-separated collector IDs to filter." }, + "status": { "type": "string", "description": "completed, partial, disqualified, overquota." } + }, + "required": ["surveyId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/surveys/{surveyId}/responses/bulk", + "queryParams": { + "page": "$page", + "per_page": "$per_page", + "sort_by": "$sort_by", + "sort_order": "$sort_order", + "start_created_at": "$start_created_at", + "end_created_at": "$end_created_at", + "collector_ids": "$collector_ids", + "status": "$status" + } + } + }, + { + "name": "surveymonkey_get_response", + "description": "Fetch one response in full detail.", + "parameters": { + "type": "object", + "properties": { + "surveyId": { "type": "string", "description": "Survey ID." }, + "responseId": { "type": "string", "description": "Response ID." } + }, + "required": ["surveyId", "responseId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/surveys/{surveyId}/responses/{responseId}" + } + }, + { + "name": "surveymonkey_list_collectors", + "description": "List collectors (distribution channels) attached to a survey.", + "parameters": { + "type": "object", + "properties": { + "surveyId": { "type": "string", "description": "Survey ID." } + }, + "required": ["surveyId"] + }, + "endpointMapping": { "method": "GET", "path": "/surveys/{surveyId}/collectors" } + }, + { + "name": "surveymonkey_list_contact_lists", + "description": "List contact lists in the account (used for email distribution collectors).", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/contact_lists" } + }, + { + "name": "surveymonkey_add_contact_to_list", + "description": "Add a contact to a contact list. Required: email.", + "parameters": { + "type": "object", + "properties": { + "listId": { "type": "string", "description": "Contact list ID." }, + "first_name": { "type": "string", "description": "First name." }, + "last_name": { "type": "string", "description": "Last name." }, + "email": { "type": "string", "description": "Email (required)." }, + "custom_fields": { "type": "object", "description": "Custom field map (defined in UI)." } + }, + "required": ["listId", "email"] + }, + "endpointMapping": { + "method": "POST", + "path": "/contact_lists/{listId}/contacts", + "bodyMapping": { + "first_name": "$first_name", + "last_name": "$last_name", + "email": "$email", + "custom_fields": "$custom_fields" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/surveymonkey.live.spec.ts b/packages/backend/src/adapters/intl/surveymonkey.live.spec.ts new file mode 100644 index 0000000..09d7b22 --- /dev/null +++ b/packages/backend/src/adapters/intl/surveymonkey.live.spec.ts @@ -0,0 +1,6 @@ +import * as adapter from './surveymonkey.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string; authConfig: Record } }; +describe('surveymonkey adapter — static spec conformance', () => { + it('api.surveymonkey.com/v3', () => expect(a.connector.baseUrl).toBe('https://api.surveymonkey.com/v3')); + it('Bearer auth', () => expect(a.connector.authType).toBe('BEARER_TOKEN')); +}); diff --git a/packages/backend/src/adapters/intl/tally.json b/packages/backend/src/adapters/intl/tally.json new file mode 100644 index 0000000..05dd60b --- /dev/null +++ b/packages/backend/src/adapters/intl/tally.json @@ -0,0 +1,117 @@ +{ + "slug": "tally", + "name": "Tally", + "description": "Read Tally form submissions and manage forms/workspaces from any AI agent. 6 tools, Bearer-token auth. The modern free-tier-friendly alternative to Typeform.", + "instructions": "This connector uses the Tally API (developers.tally.so).\n\n**Setup**:\n1. Sign in to https://tally.so → top-right avatar → **Settings → API → Create API key**.\n2. Copy the key (starts with `tly-`). Set `TALLY_API_KEY`.\n\n**Authentication**: `Authorization: Bearer ${TALLY_API_KEY}`.\n\n**Forms and responses**: each form has an alphanumeric ID (e.g. `wbN0Wd`). Responses to a form are returned as an array of `{questionId, answer, type}` per submission.\n\n**Pagination**: cursor-based with `?page=N&limit=M` for submissions. Default 50 per page, max 100.\n\n**Webhooks** out of scope (configure in Tally UI to point at your hosted endpoint).\n\n**Out of scope here**: creating/editing forms (Tally's editor is GUI-only and rich), file uploads, custom integrations.", + "region": "intl", + "category": "forms", + "icon": "tally", + "docsUrl": "https://developers.tally.so/api-reference/", + "requiredEnvVars": ["TALLY_API_KEY"], + "connector": { + "name": "Tally API", + "type": "REST", + "baseUrl": "https://api.tally.so", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{TALLY_API_KEY}}" + } + }, + "tools": [ + { + "name": "tally_list_workspaces", + "description": "List workspaces (teams). Returns id, name, slug, isOwner, isLeader.", + "parameters": { + "type": "object", + "properties": { + "page": { "type": "integer", "description": "Page number." }, + "limit": { "type": "integer", "description": "Per page (default 50, max 100)." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/workspaces", + "queryParams": { "page": "$page", "limit": "$limit" } + } + }, + { + "name": "tally_list_forms", + "description": "List forms in a workspace. Each form has id, name, isClosed, numberOfSubmissions, createdAt, updatedAt.", + "parameters": { + "type": "object", + "properties": { + "workspaceId": { "type": "string", "description": "Workspace ID to filter (optional — defaults to all)." }, + "page": { "type": "integer", "description": "Page." }, + "limit": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/forms", + "queryParams": { + "workspaceId": "$workspaceId", + "page": "$page", + "limit": "$limit" + } + } + }, + { + "name": "tally_get_form", + "description": "Fetch a form's full definition: questions[], theme, settings, redirectUrl, isClosed.", + "parameters": { + "type": "object", + "properties": { + "formId": { "type": "string", "description": "Form ID." } + }, + "required": ["formId"] + }, + "endpointMapping": { "method": "GET", "path": "/forms/{formId}" } + }, + { + "name": "tally_list_form_submissions", + "description": "List submissions to a form. Each submission has id, formId, respondentId, submittedAt, isCompleted, responses[].", + "parameters": { + "type": "object", + "properties": { + "formId": { "type": "string", "description": "Form ID." }, + "page": { "type": "integer", "description": "Page." }, + "limit": { "type": "integer", "description": "Per page." } + }, + "required": ["formId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/forms/{formId}/submissions", + "queryParams": { "page": "$page", "limit": "$limit" } + } + }, + { + "name": "tally_get_form_submission", + "description": "Fetch a single submission with its full responses[].", + "parameters": { + "type": "object", + "properties": { + "formId": { "type": "string", "description": "Form ID." }, + "submissionId": { "type": "string", "description": "Submission ID." } + }, + "required": ["formId", "submissionId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/forms/{formId}/submissions/{submissionId}" + } + }, + { + "name": "tally_get_form_questions", + "description": "List the questions defined on a form, with id, type, title, isRequired, options. Required to map answer questionIds to human-readable titles.", + "parameters": { + "type": "object", + "properties": { + "formId": { "type": "string", "description": "Form ID." } + }, + "required": ["formId"] + }, + "endpointMapping": { "method": "GET", "path": "/forms/{formId}/questions" } + } + ] +} diff --git a/packages/backend/src/adapters/intl/tally.live.spec.ts b/packages/backend/src/adapters/intl/tally.live.spec.ts new file mode 100644 index 0000000..da847e2 --- /dev/null +++ b/packages/backend/src/adapters/intl/tally.live.spec.ts @@ -0,0 +1,6 @@ +import * as adapter from './tally.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string; authConfig: Record } }; +describe('tally adapter — static spec conformance', () => { + it('api.tally.so', () => expect(a.connector.baseUrl).toBe('https://api.tally.so')); + it('Bearer auth', () => expect(a.connector.authType).toBe('BEARER_TOKEN')); +}); From fca9af5a81f7d298ca08846bc0b5a5f1c8c4854d Mon Sep 17 00:00:00 2001 From: Matteo Date: Wed, 20 May 2026 12:53:21 +0200 Subject: [PATCH 10/19] connectors: add Ghost, Substack, Reddit, Help Scout, Front MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 7 — publishing + social + customer support. - Ghost Admin v5: 12 tools — posts/pages/tags/members/newsletters CRUD with the required optimistic-concurrency updated_at field on writes. JWT-based auth (operator generates short-lived JWT via Ghost SDK and passes as GHOST_ADMIN_JWT env var). - Substack public: 5 tools — public posts/comments/RSS read. NO auth. Substack has no public write API, so this is read-only by necessity. - Reddit OAuth2: 12 tools — subreddit listings, search, comments/posts/submit/vote/save, my subreddits. Uses oauth.reddit.com (not www.) for authenticated requests. Reddit REQUIRES a meaningful User-Agent header — adapter pins one via extraHeaders on API_KEY auth (the engine extension shipped in the Copper commit). - Help Scout Mailbox v2: 13 tools — conversations CRUD with the HAL+JSON spec, threads (replies + internal notes), customers, tags, users. PATCH for status transitions uses JSON Patch ops. - Front: 13 tools — conversations with filter[q] syntax, messages, replies (creates new message in conversation), internal comments, contacts with multi-handle support, tags, teammates. Catalog: 80 adapters (40/81 of the greenfield batch done — ~50%). --- packages/backend/src/adapters/catalog.ts | 10 + packages/backend/src/adapters/intl/front.json | 279 +++++++++++++++ .../src/adapters/intl/front.live.spec.ts | 6 + packages/backend/src/adapters/intl/ghost.json | 259 ++++++++++++++ .../src/adapters/intl/ghost.live.spec.ts | 9 + .../backend/src/adapters/intl/help-scout.json | 327 ++++++++++++++++++ .../src/adapters/intl/help-scout.live.spec.ts | 6 + .../backend/src/adapters/intl/reddit.json | 276 +++++++++++++++ .../src/adapters/intl/reddit.live.spec.ts | 9 + .../backend/src/adapters/intl/substack.json | 93 +++++ .../src/adapters/intl/substack.live.spec.ts | 6 + 11 files changed, 1280 insertions(+) create mode 100644 packages/backend/src/adapters/intl/front.json create mode 100644 packages/backend/src/adapters/intl/front.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/ghost.json create mode 100644 packages/backend/src/adapters/intl/ghost.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/help-scout.json create mode 100644 packages/backend/src/adapters/intl/help-scout.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/reddit.json create mode 100644 packages/backend/src/adapters/intl/reddit.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/substack.json create mode 100644 packages/backend/src/adapters/intl/substack.live.spec.ts diff --git a/packages/backend/src/adapters/catalog.ts b/packages/backend/src/adapters/catalog.ts index 6dabe03..160af25 100644 --- a/packages/backend/src/adapters/catalog.ts +++ b/packages/backend/src/adapters/catalog.ts @@ -43,6 +43,9 @@ import * as convertkit from './intl/convertkit.json'; import * as copper from './intl/copper.json'; import * as discordBot from './intl/discord-bot.json'; import * as freshdesk from './intl/freshdesk.json'; +import * as front from './intl/front.json'; +import * as ghost from './intl/ghost.json'; +import * as helpScout from './intl/help-scout.json'; import * as hunter from './intl/hunter.json'; import * as klaviyo from './intl/klaviyo.json'; import * as lemlist from './intl/lemlist.json'; @@ -57,9 +60,11 @@ import * as nominatim from './intl/nominatim.json'; import * as outreach from './intl/outreach.json'; import * as pandadoc from './intl/pandadoc.json'; import * as pipedrive from './intl/pipedrive.json'; +import * as reddit from './intl/reddit.json'; import * as salesloft from './intl/salesloft.json'; import * as sendgrid from './intl/sendgrid.json'; import * as sorare from './intl/sorare.json'; +import * as substack from './intl/substack.json'; import * as surveymonkey from './intl/surveymonkey.json'; import * as tally from './intl/tally.json'; import * as telegramBot from './intl/telegram-bot.json'; @@ -190,6 +195,9 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ copper as unknown as AdapterDefinition, discordBot as unknown as AdapterDefinition, freshdesk as unknown as AdapterDefinition, + front as unknown as AdapterDefinition, + ghost as unknown as AdapterDefinition, + helpScout as unknown as AdapterDefinition, hunter as unknown as AdapterDefinition, klaviyo as unknown as AdapterDefinition, lemlist as unknown as AdapterDefinition, @@ -204,9 +212,11 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ outreach as unknown as AdapterDefinition, pandadoc as unknown as AdapterDefinition, pipedrive as unknown as AdapterDefinition, + reddit as unknown as AdapterDefinition, salesloft as unknown as AdapterDefinition, sendgrid as unknown as AdapterDefinition, sorare as unknown as AdapterDefinition, + substack as unknown as AdapterDefinition, surveymonkey as unknown as AdapterDefinition, tally as unknown as AdapterDefinition, telegramBot as unknown as AdapterDefinition, diff --git a/packages/backend/src/adapters/intl/front.json b/packages/backend/src/adapters/intl/front.json new file mode 100644 index 0000000..9f28a84 --- /dev/null +++ b/packages/backend/src/adapters/intl/front.json @@ -0,0 +1,279 @@ +{ + "slug": "front", + "name": "Front", + "description": "Drive Front (collaborative inbox) from any AI agent: conversations, messages, contacts, tags, inboxes, teammates. 13 tools, API-key Bearer auth.", + "instructions": "This connector uses the Front Core API (dev.frontapp.com/reference).\n\n**Setup**:\n1. Sign in to Front → **Settings (top-right) → Developers → API Tokens → Create API Token**.\n2. Pick scope: `private` (full-access to all inboxes/conversations) or scoped to a specific inbox/role.\n3. Copy the token. Set `FRONT_API_TOKEN`.\n\n**Authentication**: `Authorization: Bearer ${FRONT_API_TOKEN}`.\n\n**Front's tile model**:\n - **Conversation** = a thread between your team and one or more contacts. Has status (open/archived/spam/deleted), assignee, inbox, tags.\n - **Message** = each inbound/outbound email, SMS, chat, etc. within a conversation.\n - **Contact** = an external entity (a customer). Has handles[] of various types (email, phone, twitter, custom).\n - **Inbox** = a shared mailbox (could map to an email address, a Slack channel, an SMS number).\n - **Teammate** = a Front user.\n\n**Sending replies**: a 'reply' is a new message in an existing conversation. POST `/conversations/{id}/messages` (the adapter wraps as `front_send_reply`). For new outbound conversations, POST `/channels/{id}/messages` (different endpoint).\n\n**Pagination**: cursor-based via `?limit=N&page_token=...`. Response includes `_pagination.next` URL.\n\n**Tagged dates**: dates on objects come as Unix timestamps (seconds, NOT ms).\n\n**Resource references**: many fields are URLs like `https://api2.frontapp.com/contacts/cnt_xxx` instead of plain IDs — extract the ID after the last `/` if you need a bare ID, or just pass the URL back to Front in subsequent calls (Front accepts both).\n\n**Rate limits**: ~50 req/sec per token (varies). On 429 honor headers.\n\n**Out of scope here**: rules/automation, analytics endpoints, custom-channel creation, signature management, knowledge-base.", + "region": "intl", + "category": "support", + "icon": "front", + "docsUrl": "https://dev.frontapp.com/reference", + "requiredEnvVars": ["FRONT_API_TOKEN"], + "connector": { + "name": "Front API", + "type": "REST", + "baseUrl": "https://api2.frontapp.com", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{FRONT_API_TOKEN}}" + } + }, + "tools": [ + { + "name": "front_me", + "description": "Return the API token's identity (id, name, email).", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/me" } + }, + { + "name": "front_list_inboxes", + "description": "List inboxes the token can access. Each has id, name, type (private/shared), send_as.", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Per page (max 100)." }, + "page_token": { "type": "string", "description": "Pagination cursor." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/inboxes", + "queryParams": { "limit": "$limit", "page_token": "$page_token" } + } + }, + { + "name": "front_list_conversations", + "description": "List conversations with optional filtering. Front's filter syntax: `q[statuses][]=open&q[statuses][]=archived&q[tag_ids][]=tag_xxx`.", + "parameters": { + "type": "object", + "properties": { + "q_statuses": { "type": "array", "description": "Filter statuses: open, archived, spam, deleted." }, + "q_tag_ids": { "type": "array", "description": "Filter by tag IDs." }, + "q_inbox_id": { "type": "string", "description": "Filter by inbox ID." }, + "q_assignee_id": { "type": "string", "description": "Filter by assignee." }, + "q_after": { "type": "integer", "description": "Unix timestamp (seconds) — created after." }, + "q_before": { "type": "integer", "description": "Unix timestamp — created before." }, + "limit": { "type": "integer", "description": "Per page." }, + "page_token": { "type": "string", "description": "Cursor." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/conversations", + "queryParams": { + "q[statuses][]": "$q_statuses", + "q[tag_ids][]": "$q_tag_ids", + "q[inbox_id]": "$q_inbox_id", + "q[assignee_id]": "$q_assignee_id", + "q[after]": "$q_after", + "q[before]": "$q_before", + "limit": "$limit", + "page_token": "$page_token" + } + } + }, + { + "name": "front_get_conversation", + "description": "Fetch a single conversation including assignee, tags, recipient handles, links to messages/comments.", + "parameters": { + "type": "object", + "properties": { + "conversationId": { "type": "string", "description": "Conversation ID (starts with 'cnv_')." } + }, + "required": ["conversationId"] + }, + "endpointMapping": { "method": "GET", "path": "/conversations/{conversationId}" } + }, + { + "name": "front_list_conversation_messages", + "description": "List all messages in a conversation chronologically.", + "parameters": { + "type": "object", + "properties": { + "conversationId": { "type": "string", "description": "Conversation ID." }, + "limit": { "type": "integer", "description": "Per page." }, + "page_token": { "type": "string", "description": "Cursor." } + }, + "required": ["conversationId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/conversations/{conversationId}/messages", + "queryParams": { "limit": "$limit", "page_token": "$page_token" } + } + }, + { + "name": "front_send_reply", + "description": "Send a reply (new message) in an existing conversation. Required: body. Sender is the API token's identity unless `author_id` overrides.", + "parameters": { + "type": "object", + "properties": { + "conversationId": { "type": "string", "description": "Conversation ID." }, + "body": { "type": "string", "description": "Reply body (HTML for email, plain for SMS/chat)." }, + "text": { "type": "string", "description": "Plain-text fallback (for HTML body)." }, + "to": { "type": "array", "description": "Override recipients (defaults to conversation participants)." }, + "cc": { "type": "array", "description": "CC list." }, + "bcc": { "type": "array", "description": "BCC list." }, + "subject": { "type": "string", "description": "Override subject (defaults to conversation subject)." }, + "author_id": { "type": "string", "description": "Teammate ID — send AS this person (impersonation, requires admin scope)." }, + "channel_id": { "type": "string", "description": "Channel to send through (if conversation has multiple)." }, + "options": { "type": "object", "description": "{archive?:bool, tag_ids?:[]} — auto-archive on send, tag." } + }, + "required": ["conversationId", "body"] + }, + "endpointMapping": { + "method": "POST", + "path": "/conversations/{conversationId}/messages", + "bodyMapping": { + "body": "$body", + "text": "$text", + "to": "$to", + "cc": "$cc", + "bcc": "$bcc", + "subject": "$subject", + "author_id": "$author_id", + "channel_id": "$channel_id", + "options": "$options" + } + } + }, + { + "name": "front_add_comment", + "description": "Add an internal comment (private note) to a conversation. Only visible to teammates.", + "parameters": { + "type": "object", + "properties": { + "conversationId": { "type": "string", "description": "Conversation ID." }, + "body": { "type": "string", "description": "Comment text (Markdown)." }, + "author_id": { "type": "string", "description": "Teammate ID (defaults to token's user)." } + }, + "required": ["conversationId", "body"] + }, + "endpointMapping": { + "method": "POST", + "path": "/conversations/{conversationId}/comments", + "bodyMapping": { + "body": "$body", + "author_id": "$author_id" + } + } + }, + { + "name": "front_update_conversation", + "description": "Update conversation status, assignee, or tags. PATCH semantics — only pass what to change.", + "parameters": { + "type": "object", + "properties": { + "conversationId": { "type": "string", "description": "Conversation ID." }, + "status": { "type": "string", "description": "open, archived, spam, deleted." }, + "assignee_id": { "type": "string", "description": "Teammate ID to assign (or null to unassign)." }, + "tag_ids": { "type": "array", "description": "Replace tag set." }, + "inbox_id": { "type": "string", "description": "Move to different inbox." } + }, + "required": ["conversationId"] + }, + "endpointMapping": { + "method": "PATCH", + "path": "/conversations/{conversationId}", + "bodyMapping": { + "status": "$status", + "assignee_id": "$assignee_id", + "tag_ids": "$tag_ids", + "inbox_id": "$inbox_id" + } + } + }, + { + "name": "front_list_contacts", + "description": "List contacts (external entities).", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Per page." }, + "page_token": { "type": "string", "description": "Cursor." }, + "q_updated_after": { "type": "integer", "description": "Unix seconds." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/contacts", + "queryParams": { + "limit": "$limit", + "page_token": "$page_token", + "q[updated_after]": "$q_updated_after" + } + } + }, + { + "name": "front_get_contact", + "description": "Fetch a contact with all handles[] (email/phone/twitter/custom).", + "parameters": { + "type": "object", + "properties": { + "contactId": { "type": "string", "description": "Contact ID (starts with 'cnt_') OR handle URL like 'alt:email:a@b.com'." } + }, + "required": ["contactId"] + }, + "endpointMapping": { "method": "GET", "path": "/contacts/{contactId}" } + }, + { + "name": "front_create_contact", + "description": "Create a contact. Required: handles (at least one).", + "parameters": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Contact name." }, + "description": { "type": "string", "description": "Notes." }, + "avatar_url": { "type": "string", "description": "Avatar URL." }, + "handles": { "type": "array", "description": "[{handle:'a@b.com', source:'email'}] — source can be email, phone, twitter, custom, etc." }, + "links": { "type": "array", "description": "Array of URL strings (e.g. their CRM record)." }, + "custom_fields": { "type": "object", "description": "Custom field name → value." } + }, + "required": ["handles"] + }, + "endpointMapping": { + "method": "POST", + "path": "/contacts", + "bodyMapping": { + "name": "$name", + "description": "$description", + "avatar_url": "$avatar_url", + "handles": "$handles", + "links": "$links", + "custom_fields": "$custom_fields" + } + } + }, + { + "name": "front_list_tags", + "description": "List tags.", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Per page." }, + "page_token": { "type": "string", "description": "Cursor." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/tags", + "queryParams": { "limit": "$limit", "page_token": "$page_token" } + } + }, + { + "name": "front_list_teammates", + "description": "List teammates (Front users).", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Per page." }, + "page_token": { "type": "string", "description": "Cursor." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/teammates", + "queryParams": { "limit": "$limit", "page_token": "$page_token" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/front.live.spec.ts b/packages/backend/src/adapters/intl/front.live.spec.ts new file mode 100644 index 0000000..fb25503 --- /dev/null +++ b/packages/backend/src/adapters/intl/front.live.spec.ts @@ -0,0 +1,6 @@ +import * as adapter from './front.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string; authConfig: Record } }; +describe('front adapter — static spec conformance', () => { + it('api2.frontapp.com', () => expect(a.connector.baseUrl).toBe('https://api2.frontapp.com')); + it('Bearer auth', () => expect(a.connector.authType).toBe('BEARER_TOKEN')); +}); diff --git a/packages/backend/src/adapters/intl/ghost.json b/packages/backend/src/adapters/intl/ghost.json new file mode 100644 index 0000000..682e3b7 --- /dev/null +++ b/packages/backend/src/adapters/intl/ghost.json @@ -0,0 +1,259 @@ +{ + "slug": "ghost", + "name": "Ghost", + "description": "Drive Ghost (open-source publishing platform) from any AI agent: posts, pages, tags, members, newsletters. 12 tools, Admin API JWT auth.", + "instructions": "This connector uses the Ghost Admin API v5 (ghost.org/docs/admin-api/).\n\n**Setup**:\n1. Sign in to Ghost Admin → **Settings → Advanced → Integrations → Add custom integration**.\n2. Copy the **Admin API Key** — format is `:` (e.g. `5f3a...c4:b9d8...e2`).\n3. Set:\n - `GHOST_ADMIN_API_URL` = your Ghost site's admin API base, e.g. `https://yoursite.com/ghost/api/admin`\n - `GHOST_ADMIN_API_KEY` = the full `id:secret` key from step 2\n\n**Authentication — JWT generation required**: Ghost's Admin API does NOT accept the raw API key. Each request needs a JWT signed with the secret half. The JWT is short-lived (5 min) and has these claims:\n```\n alg: 'HS256', kid: , typ: 'JWT'\n exp: now+5min, iat: now, aud: '/admin/'\n```\nThis connector currently expects the OPERATOR or external orchestrator to **generate the JWT and pass it as `GHOST_ADMIN_JWT`** — the engine then sends `Authorization: Ghost ${GHOST_ADMIN_JWT}`. For a proper LOGIN_TOKEN-style auto-rotation see the Sorare adapter as the reference pattern; that's a future enhancement.\n\n**Workaround for testing**: use the official `@tryghost/admin-api` JS SDK to generate a JWT and copy it as `GHOST_ADMIN_JWT`. Or use the Ghost CLI: `ghost generate-jwt`.\n\n**Content API alternative**: for read-only public-published content, the simpler **Content API** uses a single key as a query parameter — no JWT. Switch baseUrl to `/ghost/api/content` and use a Content API key.\n\n**Post status**: draft, published, scheduled, sent.\n\n**Mobiledoc vs Lexical**: Ghost stores post content in either format. Modern Ghost (v5+) defaults to Lexical (`lexical` field). Set `html` on writes — Ghost converts to its internal format.\n\n**Pagination**: `?page=N&limit=M` (max 100 for posts/pages, 15 for some). Response has `meta.pagination`.\n\n**Webhooks** out of scope.\n\n**Out of scope here**: themes, settings, redirects management, oEmbed.", + "region": "intl", + "category": "publishing", + "icon": "ghost", + "docsUrl": "https://ghost.org/docs/admin-api/", + "requiredEnvVars": ["GHOST_ADMIN_API_URL", "GHOST_ADMIN_JWT"], + "connector": { + "name": "Ghost Admin v5", + "type": "REST", + "baseUrl": "{{GHOST_ADMIN_API_URL}}", + "authType": "API_KEY", + "authConfig": { + "headerName": "Authorization", + "apiKey": "Ghost {{GHOST_ADMIN_JWT}}" + } + }, + "tools": [ + { + "name": "ghost_list_posts", + "description": "List posts. Each post has id, uuid, title, slug, html, status, published_at, authors, tags, featured.", + "parameters": { + "type": "object", + "properties": { + "filter": { "type": "string", "description": "NQL filter, e.g. 'status:published+tag:ai' or 'published_at:>=2025-01-01'." }, + "page": { "type": "integer", "description": "Page." }, + "limit": { "type": "integer", "description": "Per page (max 100)." }, + "order": { "type": "string", "description": "field directives: 'published_at desc'." }, + "include": { "type": "string", "description": "Side-load: authors, tags, count.posts." }, + "fields": { "type": "string", "description": "Sparse fieldset, comma-separated." }, + "formats": { "type": "string", "description": "html, mobiledoc, lexical, plaintext (comma-separated)." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/posts/", + "queryParams": { + "filter": "$filter", + "page": "$page", + "limit": "$limit", + "order": "$order", + "include": "$include", + "fields": "$fields", + "formats": "$formats" + } + } + }, + { + "name": "ghost_get_post", + "description": "Fetch a post by ID (or by slug with /slug/{slug}/).", + "parameters": { + "type": "object", + "properties": { + "postId": { "type": "string", "description": "Post ID." }, + "formats": { "type": "string", "description": "html, mobiledoc, lexical, plaintext." }, + "include": { "type": "string", "description": "Side-load related." } + }, + "required": ["postId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/posts/{postId}/", + "queryParams": { "formats": "$formats", "include": "$include" } + } + }, + { + "name": "ghost_create_post", + "description": "Create a post. Pass html for content. Set status='published' to publish immediately, 'scheduled' + published_at for future, 'draft' for draft.", + "parameters": { + "type": "object", + "properties": { + "posts": { + "type": "array", + "description": "Wrap your post in {posts:[{title, html, status?, slug?, custom_excerpt?, feature_image?, featured?, tags?:[{name}], authors?:[{email}], visibility?:'public'|'members'|'paid', published_at?, email_recipient_filter?:'all'|'none'|'paid'|...}]}. Ghost requires this wrapper array." + }, + "source": { "type": "string", "description": "If 'html', the engine accepts html field in posts[]." } + }, + "required": ["posts"] + }, + "endpointMapping": { + "method": "POST", + "path": "/posts/", + "bodyMapping": { + "posts": "$posts" + }, + "queryParams": { "source": "$source" } + } + }, + { + "name": "ghost_update_post", + "description": "Update a post. MUST include updated_at field matching the current value (Ghost's optimistic concurrency check) — fetch with ghost_get_post first.", + "parameters": { + "type": "object", + "properties": { + "postId": { "type": "string", "description": "Post ID." }, + "posts": { + "type": "array", + "description": "[{updated_at: , title?, html?, status?, ...}]. updated_at is required." + }, + "source": { "type": "string", "description": "html." } + }, + "required": ["postId", "posts"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/posts/{postId}/", + "bodyMapping": { "posts": "$posts" }, + "queryParams": { "source": "$source" } + } + }, + { + "name": "ghost_delete_post", + "description": "Delete a post permanently.", + "parameters": { + "type": "object", + "properties": { + "postId": { "type": "string", "description": "Post ID." } + }, + "required": ["postId"] + }, + "endpointMapping": { "method": "DELETE", "path": "/posts/{postId}/" } + }, + { + "name": "ghost_list_pages", + "description": "List static pages (separate from posts). Pages have similar shape to posts.", + "parameters": { + "type": "object", + "properties": { + "filter": { "type": "string", "description": "NQL filter." }, + "page": { "type": "integer", "description": "Page." }, + "limit": { "type": "integer", "description": "Per page." }, + "include": { "type": "string", "description": "Side-load." }, + "formats": { "type": "string", "description": "html, mobiledoc, lexical." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/pages/", + "queryParams": { + "filter": "$filter", + "page": "$page", + "limit": "$limit", + "include": "$include", + "formats": "$formats" + } + } + }, + { + "name": "ghost_list_tags", + "description": "List tags with member/post counts.", + "parameters": { + "type": "object", + "properties": { + "filter": { "type": "string", "description": "NQL filter." }, + "page": { "type": "integer", "description": "Page." }, + "limit": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/tags/", + "queryParams": { + "filter": "$filter", + "page": "$page", + "limit": "$limit" + } + } + }, + { + "name": "ghost_list_members", + "description": "List members (subscribers, both free and paid).", + "parameters": { + "type": "object", + "properties": { + "filter": { "type": "string", "description": "NQL: 'status:paid', 'created_at:>=2025-01-01'." }, + "search": { "type": "string", "description": "Substring search across name and email." }, + "page": { "type": "integer", "description": "Page." }, + "limit": { "type": "integer", "description": "Per page." }, + "order": { "type": "string", "description": "Sort, e.g. 'created_at desc'." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/members/", + "queryParams": { + "filter": "$filter", + "search": "$search", + "page": "$page", + "limit": "$limit", + "order": "$order" + } + } + }, + { + "name": "ghost_create_member", + "description": "Create a member (subscribe email).", + "parameters": { + "type": "object", + "properties": { + "members": { + "type": "array", + "description": "[{email, name?, note?, labels?:[{name}], subscribed?:true, send_email_series?:false}]." + } + }, + "required": ["members"] + }, + "endpointMapping": { + "method": "POST", + "path": "/members/", + "bodyMapping": { "members": "$members" } + } + }, + { + "name": "ghost_update_member", + "description": "Update a member.", + "parameters": { + "type": "object", + "properties": { + "memberId": { "type": "string", "description": "Member ID." }, + "members": { + "type": "array", + "description": "[{name?, note?, labels?, subscribed?, newsletters?:[{id}]}]." + } + }, + "required": ["memberId", "members"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/members/{memberId}/", + "bodyMapping": { "members": "$members" } + } + }, + { + "name": "ghost_list_newsletters", + "description": "List newsletters on the site (Ghost supports multiple newsletters per site since v5).", + "parameters": { + "type": "object", + "properties": { + "filter": { "type": "string", "description": "NQL filter, e.g. 'status:active'." }, + "limit": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/newsletters/", + "queryParams": { "filter": "$filter", "limit": "$limit" } + } + }, + { + "name": "ghost_site", + "description": "Return site info (title, description, version, url). Health check.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/site/" } + } + ] +} diff --git a/packages/backend/src/adapters/intl/ghost.live.spec.ts b/packages/backend/src/adapters/intl/ghost.live.spec.ts new file mode 100644 index 0000000..0b3b60d --- /dev/null +++ b/packages/backend/src/adapters/intl/ghost.live.spec.ts @@ -0,0 +1,9 @@ +import * as adapter from './ghost.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string; authConfig: Record } }; +describe('ghost adapter — static spec conformance', () => { + it('per-site base URL', () => expect(a.connector.baseUrl).toBe('{{GHOST_ADMIN_API_URL}}')); + it('Ghost auth prefix (NOT Bearer) with JWT', () => { + expect(a.connector.authType).toBe('API_KEY'); + expect(a.connector.authConfig.apiKey).toBe('Ghost {{GHOST_ADMIN_JWT}}'); + }); +}); diff --git a/packages/backend/src/adapters/intl/help-scout.json b/packages/backend/src/adapters/intl/help-scout.json new file mode 100644 index 0000000..0b7b67a --- /dev/null +++ b/packages/backend/src/adapters/intl/help-scout.json @@ -0,0 +1,327 @@ +{ + "slug": "help-scout", + "name": "Help Scout", + "description": "Drive Help Scout (shared inbox + helpdesk) from any AI agent: conversations, customers, mailboxes, tags, threads. 13 tools, OAuth2 Bearer auth.", + "instructions": "This connector uses the Help Scout Mailbox API v2 (developer.helpscout.com/mailbox-api).\n\n**Setup**:\n1. Sign in to Help Scout → **Manage → Apps → My Apps → Create My App**.\n2. Pick app type:\n - **Personal Access Token** (single-user): get a token from your profile → API Keys → Personal Access Token. Token expires after 2 days — refresh needed.\n - **OAuth2 App** (multi-user / longer-lived): standard OAuth2 client-credentials or auth-code flow.\n3. Set `HELPSCOUT_ACCESS_TOKEN` to the access token.\n\n**Authentication**: `Authorization: Bearer ${HELPSCOUT_ACCESS_TOKEN}`. Tokens expire — your orchestrator handles refresh.\n\n**Conversations are central**: Help Scout calls tickets 'conversations'. They live in mailboxes. Each has customer (the requester), assignee, status (active/pending/closed/spam), threads (replies/notes).\n\n**Thread types**: customer (inbound from customer), reply (outbound public), note (internal), forward (forwarded by agent), phone (logged call), chat (live-chat).\n\n**Status values**: active, pending (snoozed), closed, spam, open (legacy). Use 'closed' to resolve.\n\n**HAL+JSON**: responses follow the HAL spec — `_embedded` contains nested collections, `_links` for navigation. Pagination via `?page=N&size=M` (max 50).\n\n**Custom fields**: each mailbox has its own custom fields. Set them via `customFields` array in conversation create/update.\n\n**Rate limits**: 400 req/min per API key. On 429 back off.\n\n**Out of scope here**: Docs (separate Help Scout Docs API), Beacon, Reports analytics endpoints, webhooks management.", + "region": "intl", + "category": "support", + "icon": "helpscout", + "docsUrl": "https://developer.helpscout.com/mailbox-api/", + "requiredEnvVars": ["HELPSCOUT_ACCESS_TOKEN"], + "connector": { + "name": "Help Scout Mailbox v2", + "type": "REST", + "baseUrl": "https://api.helpscout.net/v2", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{HELPSCOUT_ACCESS_TOKEN}}" + } + }, + "tools": [ + { + "name": "help_scout_list_mailboxes", + "description": "List mailboxes (shared inboxes). Each has id, name, slug, email, createdAt.", + "parameters": { + "type": "object", + "properties": { + "page": { "type": "integer", "description": "Page (1-based)." }, + "size": { "type": "integer", "description": "Per page (default 10, max 50)." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/mailboxes", + "queryParams": { "page": "$page", "size": "$size" } + } + }, + { + "name": "help_scout_list_conversations", + "description": "List conversations with filters.", + "parameters": { + "type": "object", + "properties": { + "mailbox": { "type": "integer", "description": "Filter by mailbox ID." }, + "folder": { "type": "integer", "description": "Filter by folder ID (mailbox sub-folders)." }, + "status": { "type": "string", "description": "active, pending, closed, spam, open, all." }, + "tag": { "type": "string", "description": "Comma-separated tag names." }, + "assigned_to": { "type": "integer", "description": "Assignee user ID." }, + "modifiedSince": { "type": "string", "description": "ISO 8601 — only conversations modified after." }, + "embed": { "type": "string", "description": "threads — include threads inline (saves N+1)." }, + "query": { "type": "string", "description": "Search query (Help Scout supports advanced syntax like 'subject:foo' AND 'body:bar')." }, + "sortField": { "type": "string", "description": "createdAt, customerEmail, customerName, status, subject, modifiedAt, etc." }, + "sortOrder": { "type": "string", "description": "asc or desc." }, + "page": { "type": "integer", "description": "Page." }, + "size": { "type": "integer", "description": "Per page (max 50)." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/conversations", + "queryParams": { + "mailbox": "$mailbox", + "folder": "$folder", + "status": "$status", + "tag": "$tag", + "assigned_to": "$assigned_to", + "modifiedSince": "$modifiedSince", + "embed": "$embed", + "query": "$query", + "sortField": "$sortField", + "sortOrder": "$sortOrder", + "page": "$page", + "size": "$size" + } + } + }, + { + "name": "help_scout_get_conversation", + "description": "Fetch a single conversation with full details. Pass embed=threads to also fetch the thread history.", + "parameters": { + "type": "object", + "properties": { + "conversationId": { "type": "integer", "description": "Conversation ID." }, + "embed": { "type": "string", "description": "threads to include the conversation history." } + }, + "required": ["conversationId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/conversations/{conversationId}", + "queryParams": { "embed": "$embed" } + } + }, + { + "name": "help_scout_create_conversation", + "description": "Create a new conversation (ticket). Required: subject + customer (id OR email) + mailboxId + status + type + threads (at least one initial thread).", + "parameters": { + "type": "object", + "properties": { + "subject": { "type": "string", "description": "Conversation subject." }, + "customer": { "type": "object", "description": "{id?:N, email?:'a@b'} — at least one required." }, + "mailboxId": { "type": "integer", "description": "Target mailbox ID." }, + "type": { "type": "string", "description": "email, chat, phone." }, + "status": { "type": "string", "description": "active, pending, closed." }, + "threads": { "type": "array", "description": "Array of thread objects: [{type:'customer'|'reply'|'note', text:'...', customer?:{...}, user?:N}]. Must have at least 1." }, + "assignTo": { "type": "integer", "description": "User ID to assign." }, + "tags": { "type": "array", "description": "Tag name strings." }, + "fields": { "type": "array", "description": "Custom fields: [{id:N, value:'X'}]." } + }, + "required": ["subject", "customer", "mailboxId", "type", "status", "threads"] + }, + "endpointMapping": { + "method": "POST", + "path": "/conversations", + "bodyMapping": { + "subject": "$subject", + "customer": "$customer", + "mailboxId": "$mailboxId", + "type": "$type", + "status": "$status", + "threads": "$threads", + "assignTo": "$assignTo", + "tags": "$tags", + "fields": "$fields" + } + } + }, + { + "name": "help_scout_update_conversation_status", + "description": "Change conversation status (close, reopen, snooze). Uses JSON Patch semantics.", + "parameters": { + "type": "object", + "properties": { + "conversationId": { "type": "integer", "description": "Conversation ID." }, + "op": { "type": "string", "description": "replace." }, + "path": { "type": "string", "description": "/status." }, + "value": { "type": "string", "description": "active, pending, closed, spam." } + }, + "required": ["conversationId", "op", "path", "value"] + }, + "endpointMapping": { + "method": "PATCH", + "path": "/conversations/{conversationId}", + "bodyMapping": { + "op": "$op", + "path": "$path", + "value": "$value" + } + } + }, + { + "name": "help_scout_add_reply", + "description": "Add a public reply (emails the customer) to a conversation.", + "parameters": { + "type": "object", + "properties": { + "conversationId": { "type": "integer", "description": "Conversation ID." }, + "text": { "type": "string", "description": "Reply body (HTML supported)." }, + "user": { "type": "integer", "description": "User ID sending the reply." }, + "cc": { "type": "array", "description": "CC email addresses." }, + "bcc": { "type": "array", "description": "BCC email addresses." }, + "attachments": { "type": "array", "description": "Attachment IDs (must upload first)." } + }, + "required": ["conversationId", "text"] + }, + "endpointMapping": { + "method": "POST", + "path": "/conversations/{conversationId}/reply", + "bodyMapping": { + "text": "$text", + "user": "$user", + "cc": "$cc", + "bcc": "$bcc", + "attachments": "$attachments" + } + } + }, + { + "name": "help_scout_add_note", + "description": "Add an internal note (not visible to customer).", + "parameters": { + "type": "object", + "properties": { + "conversationId": { "type": "integer", "description": "Conversation ID." }, + "text": { "type": "string", "description": "Note body." }, + "user": { "type": "integer", "description": "User ID." } + }, + "required": ["conversationId", "text"] + }, + "endpointMapping": { + "method": "POST", + "path": "/conversations/{conversationId}/notes", + "bodyMapping": { + "text": "$text", + "user": "$user" + } + } + }, + { + "name": "help_scout_list_conversation_threads", + "description": "List all threads (replies, notes, status changes) in a conversation.", + "parameters": { + "type": "object", + "properties": { + "conversationId": { "type": "integer", "description": "Conversation ID." }, + "page": { "type": "integer", "description": "Page." } + }, + "required": ["conversationId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/conversations/{conversationId}/threads", + "queryParams": { "page": "$page" } + } + }, + { + "name": "help_scout_list_customers", + "description": "List customers (people the business interacts with).", + "parameters": { + "type": "object", + "properties": { + "firstName": { "type": "string", "description": "Filter by first name." }, + "lastName": { "type": "string", "description": "Filter by last name." }, + "email": { "type": "string", "description": "Filter by email exact-match." }, + "modifiedSince": { "type": "string", "description": "ISO 8601." }, + "query": { "type": "string", "description": "Search query." }, + "page": { "type": "integer", "description": "Page." }, + "size": { "type": "integer", "description": "Per page (max 50)." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/customers", + "queryParams": { + "firstName": "$firstName", + "lastName": "$lastName", + "email": "$email", + "modifiedSince": "$modifiedSince", + "query": "$query", + "page": "$page", + "size": "$size" + } + } + }, + { + "name": "help_scout_get_customer", + "description": "Fetch a customer's full profile including emails[], phones[], chats[], social_profiles[], address.", + "parameters": { + "type": "object", + "properties": { + "customerId": { "type": "integer", "description": "Customer ID." } + }, + "required": ["customerId"] + }, + "endpointMapping": { "method": "GET", "path": "/customers/{customerId}" } + }, + { + "name": "help_scout_create_customer", + "description": "Create a customer. Required: firstName or lastName.", + "parameters": { + "type": "object", + "properties": { + "firstName": { "type": "string", "description": "First name." }, + "lastName": { "type": "string", "description": "Last name." }, + "emails": { "type": "array", "description": "[{type:'work'|'home'|'other', value:'a@b'}]." }, + "phones": { "type": "array", "description": "[{type, value}]." }, + "address": { "type": "object", "description": "{city, country, lines:[], postalCode, state}." }, + "organization": { "type": "string", "description": "Company name." }, + "jobTitle": { "type": "string", "description": "Job title." }, + "background": { "type": "string", "description": "Notes about the customer." } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/customers", + "bodyMapping": { + "firstName": "$firstName", + "lastName": "$lastName", + "emails": "$emails", + "phones": "$phones", + "address": "$address", + "organization": "$organization", + "jobTitle": "$jobTitle", + "background": "$background" + } + } + }, + { + "name": "help_scout_list_tags", + "description": "List tags on the account.", + "parameters": { + "type": "object", + "properties": { + "page": { "type": "integer", "description": "Page." }, + "size": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/tags", + "queryParams": { "page": "$page", "size": "$size" } + } + }, + { + "name": "help_scout_list_users", + "description": "List Help Scout agents.", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "Filter by email." }, + "mailbox": { "type": "integer", "description": "Filter to users in this mailbox." }, + "page": { "type": "integer", "description": "Page." }, + "size": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/users", + "queryParams": { + "email": "$email", + "mailbox": "$mailbox", + "page": "$page", + "size": "$size" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/help-scout.live.spec.ts b/packages/backend/src/adapters/intl/help-scout.live.spec.ts new file mode 100644 index 0000000..54be02a --- /dev/null +++ b/packages/backend/src/adapters/intl/help-scout.live.spec.ts @@ -0,0 +1,6 @@ +import * as adapter from './help-scout.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string; authConfig: Record } }; +describe('help-scout adapter — static spec conformance', () => { + it('api.helpscout.net/v2', () => expect(a.connector.baseUrl).toBe('https://api.helpscout.net/v2')); + it('Bearer auth', () => expect(a.connector.authType).toBe('BEARER_TOKEN')); +}); diff --git a/packages/backend/src/adapters/intl/reddit.json b/packages/backend/src/adapters/intl/reddit.json new file mode 100644 index 0000000..aed219f --- /dev/null +++ b/packages/backend/src/adapters/intl/reddit.json @@ -0,0 +1,276 @@ +{ + "slug": "reddit", + "name": "Reddit", + "description": "Read and post to Reddit from any AI agent: subreddit listings, post search, comments, user info, submit posts/comments, vote. 12 tools, OAuth2 Bearer auth.", + "instructions": "This connector uses the Reddit OAuth2 API (reddit.com/dev/api).\n\n**Setup**:\n1. Sign in to Reddit → https://www.reddit.com/prefs/apps → **Create another app**.\n2. Pick `script` (for personal use) or `web app` (for OAuth users).\n3. Note the **client_id** (under the app name) and **client_secret**.\n4. Obtain an access token via OAuth2 password grant (for script apps) or auth-code (for web apps). Endpoint: `POST https://www.reddit.com/api/v1/access_token` with HTTP Basic (client_id:client_secret).\n5. Set `REDDIT_ACCESS_TOKEN`.\n\n**Authentication**: `Authorization: Bearer ${REDDIT_ACCESS_TOKEN}`. Access tokens expire after 1 hour — your orchestrator must refresh.\n\n**User-Agent header REQUIRED**: Reddit blocks requests without a meaningful User-Agent. The adapter pins `User-Agent: AnythingMCP/1.0 (by /u/anythingmcp)`. If Reddit returns 429 unexpectedly, change this string to one specific to your app (Reddit recommends `:: (by /u/)`).\n\n**oauth.reddit.com vs www.reddit.com**: authenticated requests MUST go to `https://oauth.reddit.com` (the adapter does this). Anonymous calls go to www.reddit.com.\n\n**Resource model**: every Reddit object has a `kind` prefix:\n - t1_ = comment\n - t2_ = user\n - t3_ = link/post\n - t4_ = message\n - t5_ = subreddit\nA full ID is `kind_id36`, e.g. `t3_abc123`. Plain `id36` works for some endpoints.\n\n**Listings (pagination)**: most list endpoints return a `Listing` with `after` and `before` cursors. Pass `after=t3_abc123` to paginate.\n\n**Rate limits**: 100 queries / minute / OAuth client (free tier as of 2025; commercial tiers higher). Reddit's API became paid for high-volume use in 2023 — read https://www.redditinc.com/policies/data-api-terms.\n\n**Out of scope here**: PRAW-style streaming, moderator actions (ban/approve), wiki edits, multireddits CRUD, scheduled posts.", + "region": "intl", + "category": "social", + "icon": "reddit", + "docsUrl": "https://www.reddit.com/dev/api", + "requiredEnvVars": ["REDDIT_ACCESS_TOKEN"], + "connector": { + "name": "Reddit API", + "type": "REST", + "baseUrl": "https://oauth.reddit.com", + "authType": "API_KEY", + "authConfig": { + "headerName": "Authorization", + "apiKey": "Bearer {{REDDIT_ACCESS_TOKEN}}", + "extraHeaders": { + "User-Agent": "AnythingMCP/1.0 (by /u/anythingmcp)" + } + } + }, + "tools": [ + { + "name": "reddit_me", + "description": "Return the user the token belongs to (whoami): id, name, link_karma, comment_karma, created_utc, is_gold.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/api/v1/me" } + }, + { + "name": "reddit_get_subreddit_about", + "description": "Fetch a subreddit's info: id, display_name, title, public_description, subscribers, active_user_count, over18, created_utc.", + "parameters": { + "type": "object", + "properties": { + "subreddit": { "type": "string", "description": "Subreddit name without the 'r/' prefix, e.g. 'programming'." } + }, + "required": ["subreddit"] + }, + "endpointMapping": { "method": "GET", "path": "/r/{subreddit}/about" } + }, + { + "name": "reddit_list_subreddit_posts", + "description": "List posts in a subreddit, sorted by hot/new/top/rising. Each post has id, title, author, subreddit, score, num_comments, created_utc, selftext, url, permalink, over_18.", + "parameters": { + "type": "object", + "properties": { + "subreddit": { "type": "string", "description": "Subreddit name." }, + "sort": { "type": "string", "description": "hot, new, top, rising, controversial." }, + "t": { "type": "string", "description": "Time scope for top/controversial: hour, day, week, month, year, all." }, + "limit": { "type": "integer", "description": "Per page (default 25, max 100)." }, + "after": { "type": "string", "description": "Cursor — fullname (t3_xxx) of the post to start after." }, + "before": { "type": "string", "description": "Cursor — fullname of the post to start before." } + }, + "required": ["subreddit"] + }, + "endpointMapping": { + "method": "GET", + "path": "/r/{subreddit}/{sort}", + "queryParams": { + "t": "$t", + "limit": "$limit", + "after": "$after", + "before": "$before" + } + } + }, + { + "name": "reddit_search", + "description": "Search posts globally or within a subreddit. Supports query operators: 'title:foo', 'author:bar', 'subreddit:baz', 'site:domain.com', 'self:yes', timestamp:>1234567890.", + "parameters": { + "type": "object", + "properties": { + "q": { "type": "string", "description": "Search query." }, + "subreddit": { "type": "string", "description": "Restrict to this subreddit." }, + "sort": { "type": "string", "description": "relevance, hot, top, new, comments." }, + "t": { "type": "string", "description": "Time scope: hour, day, week, month, year, all." }, + "limit": { "type": "integer", "description": "Per page." }, + "after": { "type": "string", "description": "Cursor." }, + "restrict_sr": { "type": "boolean", "description": "If true, restrict_sr=1 (only match in subreddit, used with subreddit param)." }, + "type": { "type": "string", "description": "sr (subreddits), user (users), link (posts only)." } + }, + "required": ["q"] + }, + "endpointMapping": { + "method": "GET", + "path": "/search", + "queryParams": { + "q": "$q", + "sort": "$sort", + "t": "$t", + "limit": "$limit", + "after": "$after", + "restrict_sr": "$restrict_sr", + "type": "$type" + } + } + }, + { + "name": "reddit_get_post_comments", + "description": "Fetch a post with its comment tree. Comments can be deep — use depth/limit to control.", + "parameters": { + "type": "object", + "properties": { + "postId": { "type": "string", "description": "Post id36 (without t3_ prefix)." }, + "limit": { "type": "integer", "description": "Max comments." }, + "depth": { "type": "integer", "description": "Max thread depth." }, + "sort": { "type": "string", "description": "best, top, new, controversial, old, qa." } + }, + "required": ["postId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/comments/{postId}", + "queryParams": { + "limit": "$limit", + "depth": "$depth", + "sort": "$sort" + } + } + }, + { + "name": "reddit_get_user_about", + "description": "Fetch a user's public profile: id, name, link_karma, comment_karma, created_utc, is_gold, verified.", + "parameters": { + "type": "object", + "properties": { + "username": { "type": "string", "description": "Reddit username without u/." } + }, + "required": ["username"] + }, + "endpointMapping": { "method": "GET", "path": "/user/{username}/about" } + }, + { + "name": "reddit_get_user_posts", + "description": "List a user's submitted posts.", + "parameters": { + "type": "object", + "properties": { + "username": { "type": "string", "description": "Username." }, + "sort": { "type": "string", "description": "new, hot, top, controversial." }, + "t": { "type": "string", "description": "Time scope." }, + "limit": { "type": "integer", "description": "Per page." }, + "after": { "type": "string", "description": "Cursor." } + }, + "required": ["username"] + }, + "endpointMapping": { + "method": "GET", + "path": "/user/{username}/submitted", + "queryParams": { + "sort": "$sort", + "t": "$t", + "limit": "$limit", + "after": "$after" + } + } + }, + { + "name": "reddit_submit_post", + "description": "Submit a new post. kind='self' (text post with selftext) or 'link' (URL post with url). Required scope: 'submit'.", + "parameters": { + "type": "object", + "properties": { + "sr": { "type": "string", "description": "Subreddit name to post to." }, + "kind": { "type": "string", "description": "self or link." }, + "title": { "type": "string", "description": "Post title." }, + "text": { "type": "string", "description": "For kind=self: the markdown body." }, + "url": { "type": "string", "description": "For kind=link: the URL." }, + "flair_id": { "type": "string", "description": "Flair template ID (optional)." }, + "flair_text": { "type": "string", "description": "Flair text override." }, + "nsfw": { "type": "boolean", "description": "Mark NSFW." }, + "spoiler": { "type": "boolean", "description": "Mark spoiler." }, + "sendreplies": { "type": "boolean", "description": "Send inbox replies for comments. Default true." } + }, + "required": ["sr", "kind", "title"] + }, + "endpointMapping": { + "method": "POST", + "path": "/api/submit", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { + "sr": "$sr", + "kind": "$kind", + "title": "$title", + "text": "$text", + "url": "$url", + "flair_id": "$flair_id", + "flair_text": "$flair_text", + "nsfw": "$nsfw", + "spoiler": "$spoiler", + "sendreplies": "$sendreplies", + "api_type": "json" + } + } + }, + { + "name": "reddit_post_comment", + "description": "Reply to a post or comment. `thing_id` is the fullname (t1_xxx for comment, t3_xxx for post).", + "parameters": { + "type": "object", + "properties": { + "thing_id": { "type": "string", "description": "Fullname (e.g. 't3_abc123' or 't1_xyz789')." }, + "text": { "type": "string", "description": "Comment body (Markdown)." } + }, + "required": ["thing_id", "text"] + }, + "endpointMapping": { + "method": "POST", + "path": "/api/comment", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { + "thing_id": "$thing_id", + "text": "$text", + "api_type": "json" + } + } + }, + { + "name": "reddit_vote", + "description": "Vote on a post or comment. dir=1 upvote, dir=-1 downvote, dir=0 remove vote.", + "parameters": { + "type": "object", + "properties": { + "id": { "type": "string", "description": "Fullname (t1_xxx or t3_xxx)." }, + "dir": { "type": "integer", "description": "1, -1, or 0." } + }, + "required": ["id", "dir"] + }, + "endpointMapping": { + "method": "POST", + "path": "/api/vote", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { + "id": "$id", + "dir": "$dir" + } + } + }, + { + "name": "reddit_save", + "description": "Save (bookmark) a post or comment to the user's account.", + "parameters": { + "type": "object", + "properties": { + "id": { "type": "string", "description": "Fullname (t1_xxx or t3_xxx)." } + }, + "required": ["id"] + }, + "endpointMapping": { + "method": "POST", + "path": "/api/save", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { "id": "$id" } + } + }, + { + "name": "reddit_my_subreddits", + "description": "List subreddits the user is subscribed to.", + "parameters": { + "type": "object", + "properties": { + "where": { "type": "string", "description": "subscriber, contributor, moderator." }, + "limit": { "type": "integer", "description": "Per page (max 100)." }, + "after": { "type": "string", "description": "Cursor." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/subreddits/mine/{where}", + "queryParams": { "limit": "$limit", "after": "$after" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/reddit.live.spec.ts b/packages/backend/src/adapters/intl/reddit.live.spec.ts new file mode 100644 index 0000000..69b73a0 --- /dev/null +++ b/packages/backend/src/adapters/intl/reddit.live.spec.ts @@ -0,0 +1,9 @@ +import * as adapter from './reddit.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string; authConfig: any } }; +describe('reddit adapter — static spec conformance', () => { + it('oauth.reddit.com (NOT www.reddit.com)', () => expect(a.connector.baseUrl).toBe('https://oauth.reddit.com')); + it('Bearer token + extraHeaders for User-Agent', () => { + expect(a.connector.authConfig.apiKey).toBe('Bearer {{REDDIT_ACCESS_TOKEN}}'); + expect(a.connector.authConfig.extraHeaders['User-Agent']).toMatch(/AnythingMCP/); + }); +}); diff --git a/packages/backend/src/adapters/intl/substack.json b/packages/backend/src/adapters/intl/substack.json new file mode 100644 index 0000000..d48ac17 --- /dev/null +++ b/packages/backend/src/adapters/intl/substack.json @@ -0,0 +1,93 @@ +{ + "slug": "substack", + "name": "Substack", + "description": "Read Substack publication content, posts, comments, podcast episodes via public RSS/JSON feeds. 5 tools, no auth required.", + "instructions": "This connector uses Substack's PUBLIC content endpoints (Substack does not yet publish a formal REST API for writes — programmatic post creation/comments require the legacy session-cookie API which isn't supported here).\n\n**Setup**: NO API KEY. Set `SUBSTACK_PUBLICATION_URL` = the publication's base URL (e.g. `https://lennysnewsletter.substack.com`). For tools that target a specific publication, the URL is taken from this env var.\n\n**Authentication**: NONE. All reads use public endpoints (the same JSON Substack's own site fetches).\n\n**Content endpoints available**:\n - `/api/v1/posts?limit=N&offset=M` — paginated posts list (each post: id, title, slug, post_date, audience, type)\n - `/api/v1/posts/by-id/{id}` — single post detail\n - `/api/v1/comments/{post_id}` — top comments on a post\n - `/api/v1/podcast/feed` — podcast episodes (audio newsletters only)\n - `/feed` — standard RSS feed\n\n**Out of scope here**: creating posts (no public write API), email subscribers list, paid-subscriber management, comment moderation. All UI-only as of 2025.\n\n**Rate limits**: Substack doesn't publish numbers; be polite (1 req/sec recommended) and cache aggressively. They'll throttle abusive IPs.", + "region": "intl", + "category": "publishing", + "icon": "substack", + "docsUrl": "https://substack.com/help/articles/4445-substack-content-api", + "requiredEnvVars": ["SUBSTACK_PUBLICATION_URL"], + "connector": { + "name": "Substack public", + "type": "REST", + "baseUrl": "{{SUBSTACK_PUBLICATION_URL}}", + "authType": "NONE" + }, + "tools": [ + { + "name": "substack_list_posts", + "description": "List recent posts from the publication. Each post has id, title, subtitle, slug, post_date, audience (everyone/only_paid/only_free/founding), type (newsletter/podcast/thread), wordcount, reactions.", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Max per page (default 12, max 50 typically)." }, + "offset": { "type": "integer", "description": "Pagination offset." }, + "sort": { "type": "string", "description": "new (most-recent first, default) or popular." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v1/posts", + "queryParams": { + "limit": "$limit", + "offset": "$offset", + "sort": "$sort" + } + } + }, + { + "name": "substack_search_posts", + "description": "Search posts in the publication.", + "parameters": { + "type": "object", + "properties": { + "query": { "type": "string", "description": "Search query." }, + "limit": { "type": "integer", "description": "Max results." } + }, + "required": ["query"] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v1/posts/search", + "queryParams": { "query": "$query", "limit": "$limit" } + } + }, + { + "name": "substack_get_post_by_id", + "description": "Fetch a single post by its numeric ID. Returns full body_html, subtitle, post_date, audience, podcast_url if applicable.", + "parameters": { + "type": "object", + "properties": { + "postId": { "type": "integer", "description": "Post numeric ID." } + }, + "required": ["postId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v1/posts/by-id/{postId}" + } + }, + { + "name": "substack_get_comments", + "description": "Get top comments on a post. Returns nested comment threads.", + "parameters": { + "type": "object", + "properties": { + "postId": { "type": "integer", "description": "Post ID." } + }, + "required": ["postId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v1/post/{postId}/comments" + } + }, + { + "name": "substack_get_rss_feed", + "description": "Fetch the publication's standard RSS feed (Atom format). Useful for systems that already speak RSS.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/feed" } + } + ] +} diff --git a/packages/backend/src/adapters/intl/substack.live.spec.ts b/packages/backend/src/adapters/intl/substack.live.spec.ts new file mode 100644 index 0000000..f8285a7 --- /dev/null +++ b/packages/backend/src/adapters/intl/substack.live.spec.ts @@ -0,0 +1,6 @@ +import * as adapter from './substack.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string } }; +describe('substack adapter — static spec conformance', () => { + it('per-publication base URL', () => expect(a.connector.baseUrl).toBe('{{SUBSTACK_PUBLICATION_URL}}')); + it('public — no auth', () => expect(a.connector.authType).toBe('NONE')); +}); From a550ca6b9a15bb248e45f092f98787c1fd4ca881 Mon Sep 17 00:00:00 2001 From: Matteo Date: Wed, 20 May 2026 12:59:17 +0200 Subject: [PATCH 11/19] connectors: add BigCommerce, Acuity, Drip, Heap, Fathom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 8 — e-commerce + scheduling + analytics + email automation. - BigCommerce v3: 14 tools — products CRUD with variants, categories, customers, orders (uses v2 endpoints for orders since v3 hasn't migrated them). X-Auth-Token header + store-hash in baseUrl. Paths explicitly carry /v2 vs /v3 prefix. - Acuity Scheduling v1: 12 tools — appointment types, calendars, availability windows (dates + times), appointments CRUD with reschedule/cancel, clients, forms. BASIC_AUTH with userId+key. - Drip v2: 12 tools — subscribers upsert, tags, events for workflow triggers, campaigns enrollment, e-commerce order tracking, custom fields discovery. BASIC_AUTH with key as username + empty password. Account-templated base URL. - Heap server-side: 5 tools — single + bulk event tracking, user properties, account properties (B2B), user-account association. authType=NONE (Heap's server-side ingest uses app_id in body). - Fathom Analytics v1: 6 tools — sites, events list/create, aggregations (the flexible query API), current visitors realtime. Catalog: 85 adapters (45/81 of the greenfield batch done, ~56%). --- packages/backend/src/adapters/catalog.ts | 10 + .../src/adapters/intl/acuity-scheduling.json | 233 +++++++ .../intl/acuity-scheduling.live.spec.ts | 10 + .../src/adapters/intl/bigcommerce.json | 621 ++++++++++++++++++ .../adapters/intl/bigcommerce.live.spec.ts | 19 + packages/backend/src/adapters/intl/drip.json | 222 +++++++ .../src/adapters/intl/drip.live.spec.ts | 11 + .../backend/src/adapters/intl/fathom.json | 149 +++++ .../src/adapters/intl/fathom.live.spec.ts | 6 + packages/backend/src/adapters/intl/heap.json | 133 ++++ .../src/adapters/intl/heap.live.spec.ts | 6 + 11 files changed, 1420 insertions(+) create mode 100644 packages/backend/src/adapters/intl/acuity-scheduling.json create mode 100644 packages/backend/src/adapters/intl/acuity-scheduling.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/bigcommerce.json create mode 100644 packages/backend/src/adapters/intl/bigcommerce.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/drip.json create mode 100644 packages/backend/src/adapters/intl/drip.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/fathom.json create mode 100644 packages/backend/src/adapters/intl/fathom.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/heap.json create mode 100644 packages/backend/src/adapters/intl/heap.live.spec.ts diff --git a/packages/backend/src/adapters/catalog.ts b/packages/backend/src/adapters/catalog.ts index 160af25..e924087 100644 --- a/packages/backend/src/adapters/catalog.ts +++ b/packages/backend/src/adapters/catalog.ts @@ -32,8 +32,10 @@ import * as xentral from './de/xentral.json'; import * as companiesHouse from './gb/companies-house.json'; import * as wise from './gb/wise.json'; import * as activecampaign from './intl/activecampaign.json'; +import * as acuityScheduling from './intl/acuity-scheduling.json'; import * as apollo from './intl/apollo.json'; import * as basecamp from './intl/basecamp.json'; +import * as bigcommerce from './intl/bigcommerce.json'; import * as brevo from './intl/brevo.json'; import * as calendly from './intl/calendly.json'; import * as clickup from './intl/clickup.json'; @@ -42,9 +44,12 @@ import * as coda from './intl/coda.json'; import * as convertkit from './intl/convertkit.json'; import * as copper from './intl/copper.json'; import * as discordBot from './intl/discord-bot.json'; +import * as drip from './intl/drip.json'; +import * as fathom from './intl/fathom.json'; import * as freshdesk from './intl/freshdesk.json'; import * as front from './intl/front.json'; import * as ghost from './intl/ghost.json'; +import * as heap from './intl/heap.json'; import * as helpScout from './intl/help-scout.json'; import * as hunter from './intl/hunter.json'; import * as klaviyo from './intl/klaviyo.json'; @@ -184,8 +189,10 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ companiesHouse as unknown as AdapterDefinition, wise as unknown as AdapterDefinition, activecampaign as unknown as AdapterDefinition, + acuityScheduling as unknown as AdapterDefinition, apollo as unknown as AdapterDefinition, basecamp as unknown as AdapterDefinition, + bigcommerce as unknown as AdapterDefinition, brevo as unknown as AdapterDefinition, calendly as unknown as AdapterDefinition, clickup as unknown as AdapterDefinition, @@ -194,9 +201,12 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ convertkit as unknown as AdapterDefinition, copper as unknown as AdapterDefinition, discordBot as unknown as AdapterDefinition, + drip as unknown as AdapterDefinition, + fathom as unknown as AdapterDefinition, freshdesk as unknown as AdapterDefinition, front as unknown as AdapterDefinition, ghost as unknown as AdapterDefinition, + heap as unknown as AdapterDefinition, helpScout as unknown as AdapterDefinition, hunter as unknown as AdapterDefinition, klaviyo as unknown as AdapterDefinition, diff --git a/packages/backend/src/adapters/intl/acuity-scheduling.json b/packages/backend/src/adapters/intl/acuity-scheduling.json new file mode 100644 index 0000000..423b8ea --- /dev/null +++ b/packages/backend/src/adapters/intl/acuity-scheduling.json @@ -0,0 +1,233 @@ +{ + "slug": "acuity-scheduling", + "name": "Acuity Scheduling", + "description": "Drive Acuity Scheduling (Squarespace-owned booking SaaS) from any AI agent: appointments, availability, appointment types, calendars, clients, products. 12 tools, Basic-auth.", + "instructions": "This connector uses the Acuity Scheduling API v1 (developers.acuityscheduling.com).\n\n**Setup**:\n1. Sign in to Acuity → **Integrations → API**.\n2. Note your **User ID** and copy the **API Key**.\n3. Set:\n - `ACUITY_USER_ID` = numeric user ID (e.g. `12345`)\n - `ACUITY_API_KEY` = the API key\n\n**Authentication**: HTTP Basic with username=USER_ID and password=API_KEY.\n\n**Availability flow**:\n 1. List appointment types (`acuity_list_appointment_types`) — get appointmentTypeID.\n 2. List calendars (`acuity_list_calendars`) — get calendarID(s).\n 3. List available dates (`acuity_list_available_dates`) for date+appointmentType+calendar.\n 4. List available times (`acuity_list_available_times`) for a specific date.\n 5. Book the appointment (`acuity_create_appointment`).\n\n**Cancellation**: appointments can be canceled (POST /appointments/{id}/cancel) — irreversible but client can rebook.\n\n**Timezones**: Acuity normalizes everything to the appointment's calendar timezone. Dates use ISO 8601 with offset.\n\n**Forms and custom fields**: appointments can have forms with custom fields — pass an array of {id, value} in `fields`.\n\n**Rate limits**: ~10 req/sec. On 429 back off.\n\n**Out of scope here**: products/packages purchases beyond list, certificates, email templates, scheduling-page customization.", + "region": "intl", + "category": "scheduling", + "icon": "acuity", + "docsUrl": "https://developers.acuityscheduling.com/reference/", + "requiredEnvVars": ["ACUITY_USER_ID", "ACUITY_API_KEY"], + "connector": { + "name": "Acuity Scheduling v1", + "type": "REST", + "baseUrl": "https://acuityscheduling.com/api/v1", + "authType": "BASIC_AUTH", + "authConfig": { + "username": "{{ACUITY_USER_ID}}", + "password": "{{ACUITY_API_KEY}}" + } + }, + "tools": [ + { + "name": "acuity_get_me", + "description": "Return account info: name, email, country, timezone. Health check.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/me" } + }, + { + "name": "acuity_list_appointment_types", + "description": "List appointment types defined in the account. Each has id, name, duration, price, color, calendarIDs[], active.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/appointment-types" } + }, + { + "name": "acuity_list_calendars", + "description": "List calendars (staff/resources). Each has id, name, email, location, description, timezone.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/calendars" } + }, + { + "name": "acuity_list_available_dates", + "description": "List dates with available slots in a month, for a given appointmentTypeID and optional calendarID.", + "parameters": { + "type": "object", + "properties": { + "appointmentTypeID": { "type": "integer", "description": "Appointment type ID." }, + "month": { "type": "string", "description": "YYYY-MM month to scan." }, + "calendarID": { "type": "integer", "description": "Optional calendar ID — limit to this staff/resource." }, + "timezone": { "type": "string", "description": "TZ database name (e.g. 'America/New_York'). Default uses account's." } + }, + "required": ["appointmentTypeID", "month"] + }, + "endpointMapping": { + "method": "GET", + "path": "/availability/dates", + "queryParams": { + "appointmentTypeID": "$appointmentTypeID", + "month": "$month", + "calendarID": "$calendarID", + "timezone": "$timezone" + } + } + }, + { + "name": "acuity_list_available_times", + "description": "List specific time slots for a date.", + "parameters": { + "type": "object", + "properties": { + "appointmentTypeID": { "type": "integer", "description": "Appointment type ID." }, + "date": { "type": "string", "description": "YYYY-MM-DD." }, + "calendarID": { "type": "integer", "description": "Optional calendar ID." }, + "timezone": { "type": "string", "description": "Timezone for the returned times." } + }, + "required": ["appointmentTypeID", "date"] + }, + "endpointMapping": { + "method": "GET", + "path": "/availability/times", + "queryParams": { + "appointmentTypeID": "$appointmentTypeID", + "date": "$date", + "calendarID": "$calendarID", + "timezone": "$timezone" + } + } + }, + { + "name": "acuity_list_appointments", + "description": "List appointments with filtering.", + "parameters": { + "type": "object", + "properties": { + "minDate": { "type": "string", "description": "ISO 8601 — appointments at/after." }, + "maxDate": { "type": "string", "description": "ISO 8601 — at/before." }, + "calendarID": { "type": "integer", "description": "Filter by calendar." }, + "appointmentTypeID": { "type": "integer", "description": "Filter by type." }, + "canceled": { "type": "boolean", "description": "If true, only canceled. Default excludes canceled." }, + "max": { "type": "integer", "description": "Max results (default 50)." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/appointments", + "queryParams": { + "minDate": "$minDate", + "maxDate": "$maxDate", + "calendarID": "$calendarID", + "appointmentTypeID": "$appointmentTypeID", + "canceled": "$canceled", + "max": "$max" + } + } + }, + { + "name": "acuity_get_appointment", + "description": "Fetch a single appointment with client + forms + payment details.", + "parameters": { + "type": "object", + "properties": { + "appointmentId": { "type": "integer", "description": "Appointment ID." } + }, + "required": ["appointmentId"] + }, + "endpointMapping": { "method": "GET", "path": "/appointments/{appointmentId}" } + }, + { + "name": "acuity_create_appointment", + "description": "Book an appointment. Required: datetime + appointmentTypeID + firstName + lastName + email.", + "parameters": { + "type": "object", + "properties": { + "datetime": { "type": "string", "description": "ISO 8601 datetime of the slot." }, + "appointmentTypeID": { "type": "integer", "description": "Appointment type ID." }, + "calendarID": { "type": "integer", "description": "Optional calendar (round-robin if omitted)." }, + "firstName": { "type": "string", "description": "Client first name." }, + "lastName": { "type": "string", "description": "Client last name." }, + "email": { "type": "string", "description": "Client email." }, + "phone": { "type": "string", "description": "Client phone." }, + "fields": { "type": "array", "description": "Custom form fields: [{id, value}]." }, + "smsOptIn": { "type": "boolean", "description": "Opt in to SMS reminders." }, + "noEmail": { "type": "boolean", "description": "If true, skip the confirmation email." }, + "labels": { "type": "array", "description": "[{id}] — apply labels." }, + "addonIDs": { "type": "array", "description": "Add-on IDs." }, + "certificate": { "type": "string", "description": "Coupon/cert code." } + }, + "required": ["datetime", "appointmentTypeID", "firstName", "lastName", "email"] + }, + "endpointMapping": { + "method": "POST", + "path": "/appointments", + "bodyMapping": { + "datetime": "$datetime", + "appointmentTypeID": "$appointmentTypeID", + "calendarID": "$calendarID", + "firstName": "$firstName", + "lastName": "$lastName", + "email": "$email", + "phone": "$phone", + "fields": "$fields", + "smsOptIn": "$smsOptIn", + "noEmail": "$noEmail", + "labels": "$labels", + "addonIDs": "$addonIDs", + "certificate": "$certificate" + } + } + }, + { + "name": "acuity_reschedule_appointment", + "description": "Move an appointment to a new datetime/calendar.", + "parameters": { + "type": "object", + "properties": { + "appointmentId": { "type": "integer", "description": "Appointment ID." }, + "datetime": { "type": "string", "description": "New ISO 8601 datetime." }, + "calendarID": { "type": "integer", "description": "Optional new calendar." } + }, + "required": ["appointmentId", "datetime"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/appointments/{appointmentId}/reschedule", + "bodyMapping": { + "datetime": "$datetime", + "calendarID": "$calendarID" + } + } + }, + { + "name": "acuity_cancel_appointment", + "description": "Cancel an appointment. Irreversible.", + "parameters": { + "type": "object", + "properties": { + "appointmentId": { "type": "integer", "description": "Appointment ID." }, + "noEmail": { "type": "boolean", "description": "If true, skip the cancellation email." }, + "cancelNote": { "type": "string", "description": "Reason note." } + }, + "required": ["appointmentId"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/appointments/{appointmentId}/cancel", + "bodyMapping": { + "noEmail": "$noEmail", + "cancelNote": "$cancelNote" + } + } + }, + { + "name": "acuity_list_clients", + "description": "List clients (people who have booked). Search by name or email.", + "parameters": { + "type": "object", + "properties": { + "search": { "type": "string", "description": "Name or email substring." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/clients", + "queryParams": { "search": "$search" } + } + }, + { + "name": "acuity_list_forms", + "description": "List intake forms (custom forms). Each has id, name, fields[]. Use field IDs in appointment create's `fields` param.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/forms" } + } + ] +} diff --git a/packages/backend/src/adapters/intl/acuity-scheduling.live.spec.ts b/packages/backend/src/adapters/intl/acuity-scheduling.live.spec.ts new file mode 100644 index 0000000..6fd5b7a --- /dev/null +++ b/packages/backend/src/adapters/intl/acuity-scheduling.live.spec.ts @@ -0,0 +1,10 @@ +import * as adapter from './acuity-scheduling.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string; authConfig: Record } }; +describe('acuity-scheduling adapter — static spec conformance', () => { + it('acuityscheduling.com/api/v1', () => expect(a.connector.baseUrl).toBe('https://acuityscheduling.com/api/v1')); + it('BASIC_AUTH with USER_ID + API_KEY', () => { + expect(a.connector.authType).toBe('BASIC_AUTH'); + expect(a.connector.authConfig.username).toBe('{{ACUITY_USER_ID}}'); + expect(a.connector.authConfig.password).toBe('{{ACUITY_API_KEY}}'); + }); +}); diff --git a/packages/backend/src/adapters/intl/bigcommerce.json b/packages/backend/src/adapters/intl/bigcommerce.json new file mode 100644 index 0000000..b08ae6a --- /dev/null +++ b/packages/backend/src/adapters/intl/bigcommerce.json @@ -0,0 +1,621 @@ +{ + "slug": "bigcommerce", + "name": "BigCommerce", + "description": "Drive BigCommerce (SaaS e-commerce platform) from any AI agent: products, variants, orders, customers, categories. 14 tools, API-key (X-Auth-Token) auth, per-store base URL.", + "instructions": "This connector uses the BigCommerce REST API v3 (developer.bigcommerce.com/docs/rest-catalog).\n\n**Setup**:\n1. Sign in to BigCommerce \u2192 **Settings \u2192 API \u2192 API Accounts \u2192 Create API Account \u2192 V2/V3 API token**.\n2. Pick scopes \u2014 at minimum: Products (Read+Write), Orders (Read+Write), Customers (Read+Write), Information & Settings (Read).\n3. Copy the **Access Token** and note the **API path** \u2014 looks like `https://api.bigcommerce.com/stores/{STORE_HASH}/v3/`.\n4. Set:\n - `BIGCOMMERCE_STORE_HASH` = the alphanumeric store hash from the URL (e.g. `abcd1234`)\n - `BIGCOMMERCE_ACCESS_TOKEN` = the access token\n\n**Authentication**: header `X-Auth-Token: ${BIGCOMMERCE_ACCESS_TOKEN}`. The base URL embeds the store hash.\n\n**v2 vs v3**: this connector uses v3 (the modern REST). Some legacy endpoints only exist in v2 (`/stores/{hash}/v2/...`) \u2014 not exposed here.\n\n**Catalog/Products**: complex hierarchy. Product has variants (SKUs), modifiers (configurable options), images, custom_fields, bulk_pricing_rules, metafields. Use `include` query param to side-load (e.g. `?include=variants,images,custom_fields`).\n\n**Order statuses**: 0=Incomplete, 1=Pending, 2=Shipped, 3=Partially Shipped, 4=Refunded, 5=Cancelled, 6=Declined, 7=Awaiting Payment, 8=Awaiting Pickup, 9=Awaiting Shipment, 10=Completed, 11=Awaiting Fulfillment, 12=Manual Verification Required, 13=Disputed, 14=Partially Refunded.\n\n**Pagination**: `?page=N&limit=M` (max 250). Response envelope: `{data:[], meta:{pagination:{total,count,per_page,current_page,total_pages,links}}}`.\n\n**Rate limits**: depends on plan \u2014 Essentials ~30k req/hour, Pro 60k, Enterprise unlimited. Returns 429 with `X-Rate-Limit-Time-Reset-Ms`.\n\n**Out of scope here**: subscriptions, B2B Edition features, Stencil themes, scripts/checkout SDK, webhooks.", + "region": "intl", + "category": "e-commerce", + "icon": "bigcommerce", + "docsUrl": "https://developer.bigcommerce.com/docs/rest-catalog", + "requiredEnvVars": [ + "BIGCOMMERCE_STORE_HASH", + "BIGCOMMERCE_ACCESS_TOKEN" + ], + "connector": { + "name": "BigCommerce v3", + "type": "REST", + "baseUrl": "https://api.bigcommerce.com/stores/{{BIGCOMMERCE_STORE_HASH}}", + "authType": "API_KEY", + "authConfig": { + "headerName": "X-Auth-Token", + "apiKey": "{{BIGCOMMERCE_ACCESS_TOKEN}}" + } + }, + "tools": [ + { + "name": "bigcommerce_list_products", + "description": "List products with filters. Each product has id, name, sku, type, price, cost_price, retail_price, weight, inventory_level, inventory_tracking, brand_id, categories[], date_created, date_modified.", + "parameters": { + "type": "object", + "properties": { + "id_in": { + "type": "string", + "description": "Comma-separated product IDs." + }, + "name": { + "type": "string", + "description": "Substring filter on name." + }, + "sku": { + "type": "string", + "description": "Exact SKU match." + }, + "categories_in": { + "type": "string", + "description": "Comma-separated category IDs." + }, + "is_visible": { + "type": "boolean", + "description": "Filter to visible products." + }, + "is_featured": { + "type": "boolean", + "description": "Filter to featured." + }, + "include": { + "type": "string", + "description": "Side-load: variants,images,custom_fields,bulk_pricing_rules,modifiers,options,videos,primary_image,reviews." + }, + "include_fields": { + "type": "string", + "description": "Comma-separated fields to return (sparse)." + }, + "page": { + "type": "integer", + "description": "1-based page." + }, + "limit": { + "type": "integer", + "description": "Per page (default 50, max 250)." + }, + "sort": { + "type": "string", + "description": "id, name, sku, price, date_modified, date_created, inventory_level, is_visible, total_sold." + }, + "direction": { + "type": "string", + "description": "asc or desc." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v3/catalog/products", + "queryParams": { + "id:in": "$id_in", + "name": "$name", + "sku": "$sku", + "categories:in": "$categories_in", + "is_visible": "$is_visible", + "is_featured": "$is_featured", + "include": "$include", + "include_fields": "$include_fields", + "page": "$page", + "limit": "$limit", + "sort": "$sort", + "direction": "$direction" + } + } + }, + { + "name": "bigcommerce_get_product", + "description": "Fetch a single product. Use include for variants/images/custom_fields.", + "parameters": { + "type": "object", + "properties": { + "productId": { + "type": "integer", + "description": "Product ID." + }, + "include": { + "type": "string", + "description": "Side-load." + } + }, + "required": [ + "productId" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v3/catalog/products/{productId}", + "queryParams": { + "include": "$include" + } + } + }, + { + "name": "bigcommerce_create_product", + "description": "Create a product. Required: name + price + categories + weight + type (physical or digital).", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Product name." + }, + "type": { + "type": "string", + "description": "physical or digital." + }, + "sku": { + "type": "string", + "description": "SKU." + }, + "description": { + "type": "string", + "description": "HTML description." + }, + "weight": { + "type": "number", + "description": "Weight (in store's unit)." + }, + "price": { + "type": "number", + "description": "Price." + }, + "cost_price": { + "type": "number", + "description": "Cost basis." + }, + "retail_price": { + "type": "number", + "description": "Compare-at retail." + }, + "sale_price": { + "type": "number", + "description": "Sale price." + }, + "categories": { + "type": "array", + "description": "Array of category IDs." + }, + "brand_id": { + "type": "integer", + "description": "Brand ID." + }, + "inventory_level": { + "type": "integer", + "description": "Stock on hand." + }, + "inventory_tracking": { + "type": "string", + "description": "none, product, variant." + }, + "is_visible": { + "type": "boolean", + "description": "Visible on storefront." + }, + "is_featured": { + "type": "boolean", + "description": "Featured." + }, + "tax_class_id": { + "type": "integer", + "description": "Tax class." + } + }, + "required": [ + "name", + "type", + "weight", + "price", + "categories" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/v3/catalog/products", + "bodyMapping": { + "name": "$name", + "type": "$type", + "sku": "$sku", + "description": "$description", + "weight": "$weight", + "price": "$price", + "cost_price": "$cost_price", + "retail_price": "$retail_price", + "sale_price": "$sale_price", + "categories": "$categories", + "brand_id": "$brand_id", + "inventory_level": "$inventory_level", + "inventory_tracking": "$inventory_tracking", + "is_visible": "$is_visible", + "is_featured": "$is_featured", + "tax_class_id": "$tax_class_id" + } + } + }, + { + "name": "bigcommerce_update_product", + "description": "Update a product. Common: price, inventory_level, is_visible.", + "parameters": { + "type": "object", + "properties": { + "productId": { + "type": "integer", + "description": "Product ID." + }, + "name": { + "type": "string", + "description": "New name." + }, + "price": { + "type": "number", + "description": "New price." + }, + "sale_price": { + "type": "number", + "description": "Sale price." + }, + "inventory_level": { + "type": "integer", + "description": "New stock." + }, + "is_visible": { + "type": "boolean", + "description": "Visibility." + }, + "description": { + "type": "string", + "description": "New description." + }, + "categories": { + "type": "array", + "description": "Replace categories." + } + }, + "required": [ + "productId" + ] + }, + "endpointMapping": { + "method": "PUT", + "path": "/v3/catalog/products/{productId}", + "bodyMapping": { + "name": "$name", + "price": "$price", + "sale_price": "$sale_price", + "inventory_level": "$inventory_level", + "is_visible": "$is_visible", + "description": "$description", + "categories": "$categories" + } + } + }, + { + "name": "bigcommerce_delete_product", + "description": "Permanently delete a product.", + "parameters": { + "type": "object", + "properties": { + "productId": { + "type": "integer", + "description": "Product ID." + } + }, + "required": [ + "productId" + ] + }, + "endpointMapping": { + "method": "DELETE", + "path": "/v3/catalog/products/{productId}" + } + }, + { + "name": "bigcommerce_list_product_variants", + "description": "List variants (SKUs) for a product.", + "parameters": { + "type": "object", + "properties": { + "productId": { + "type": "integer", + "description": "Product ID." + }, + "page": { + "type": "integer", + "description": "Page." + }, + "limit": { + "type": "integer", + "description": "Per page." + } + }, + "required": [ + "productId" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v3/catalog/products/{productId}/variants", + "queryParams": { + "page": "$page", + "limit": "$limit" + } + } + }, + { + "name": "bigcommerce_update_variant_inventory", + "description": "Update a variant's inventory_level (stock quantity).", + "parameters": { + "type": "object", + "properties": { + "productId": { + "type": "integer", + "description": "Parent product ID." + }, + "variantId": { + "type": "integer", + "description": "Variant ID." + }, + "inventory_level": { + "type": "integer", + "description": "New stock." + } + }, + "required": [ + "productId", + "variantId", + "inventory_level" + ] + }, + "endpointMapping": { + "method": "PUT", + "path": "/v3/catalog/products/{productId}/variants/{variantId}", + "bodyMapping": { + "inventory_level": "$inventory_level" + } + } + }, + { + "name": "bigcommerce_list_categories", + "description": "List categories with parent/tree structure.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Substring filter." + }, + "parent_id": { + "type": "integer", + "description": "Filter by parent." + }, + "page": { + "type": "integer", + "description": "Page." + }, + "limit": { + "type": "integer", + "description": "Per page." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v3/catalog/categories", + "queryParams": { + "name": "$name", + "parent_id": "$parent_id", + "page": "$page", + "limit": "$limit" + } + } + }, + { + "name": "bigcommerce_list_orders", + "description": "List orders (uses v2 endpoint \u2014 V3 doesn't fully cover orders yet).", + "parameters": { + "type": "object", + "properties": { + "min_id": { + "type": "integer", + "description": "Min order ID." + }, + "max_id": { + "type": "integer", + "description": "Max order ID." + }, + "min_total": { + "type": "number", + "description": "Min total." + }, + "max_total": { + "type": "number", + "description": "Max total." + }, + "customer_id": { + "type": "integer", + "description": "Filter by customer." + }, + "email": { + "type": "string", + "description": "Filter by billing email." + }, + "status_id": { + "type": "integer", + "description": "Filter by status code (see instructions)." + }, + "min_date_created": { + "type": "string", + "description": "RFC 2822 date string." + }, + "max_date_created": { + "type": "string", + "description": "RFC 2822 date string." + }, + "page": { + "type": "integer", + "description": "Page." + }, + "limit": { + "type": "integer", + "description": "Per page (max 250)." + }, + "sort": { + "type": "string", + "description": "id:asc, id:desc, total:asc, etc." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v2/orders", + "queryParams": { + "min_id": "$min_id", + "max_id": "$max_id", + "min_total": "$min_total", + "max_total": "$max_total", + "customer_id": "$customer_id", + "email": "$email", + "status_id": "$status_id", + "min_date_created": "$min_date_created", + "max_date_created": "$max_date_created", + "page": "$page", + "limit": "$limit", + "sort": "$sort" + } + } + }, + { + "name": "bigcommerce_get_order", + "description": "Fetch one order (v2). Returns customer, billing/shipping addresses, products via separate /products endpoint.", + "parameters": { + "type": "object", + "properties": { + "orderId": { + "type": "integer", + "description": "Order ID." + } + }, + "required": [ + "orderId" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v2/orders/{orderId}" + } + }, + { + "name": "bigcommerce_get_order_products", + "description": "List line items on an order.", + "parameters": { + "type": "object", + "properties": { + "orderId": { + "type": "integer", + "description": "Order ID." + } + }, + "required": [ + "orderId" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/v2/orders/{orderId}/products" + } + }, + { + "name": "bigcommerce_update_order_status", + "description": "Update an order's status (use status code from instructions).", + "parameters": { + "type": "object", + "properties": { + "orderId": { + "type": "integer", + "description": "Order ID." + }, + "status_id": { + "type": "integer", + "description": "New status code." + } + }, + "required": [ + "orderId", + "status_id" + ] + }, + "endpointMapping": { + "method": "PUT", + "path": "/v2/orders/{orderId}", + "bodyMapping": { + "status_id": "$status_id" + } + } + }, + { + "name": "bigcommerce_list_customers", + "description": "List customers (V3).", + "parameters": { + "type": "object", + "properties": { + "email_in": { + "type": "string", + "description": "Comma-separated emails." + }, + "name": { + "type": "string", + "description": "Substring on name." + }, + "company": { + "type": "string", + "description": "Company filter." + }, + "customer_group_id": { + "type": "integer", + "description": "Filter by group." + }, + "page": { + "type": "integer", + "description": "Page." + }, + "limit": { + "type": "integer", + "description": "Per page." + }, + "include": { + "type": "string", + "description": "addresses, attributes, formfields, storecredit, _consents." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v3/customers", + "queryParams": { + "email:in": "$email_in", + "name": "$name", + "company": "$company", + "customer_group_id": "$customer_group_id", + "page": "$page", + "limit": "$limit", + "include": "$include" + } + } + }, + { + "name": "bigcommerce_create_customer", + "description": "Create a customer.", + "parameters": { + "type": "object", + "properties": { + "customers": { + "type": "array", + "description": "[{first_name, last_name, email, company?, phone?, notes?, tax_exempt_category?, customer_group_id?, addresses?:[{...}], authentication?:{new_password?,force_password_reset?}}]. Wrap inside array." + } + }, + "required": [ + "customers" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/v3/customers", + "bodyTemplate": "${customers}" + } + } + ] +} \ No newline at end of file diff --git a/packages/backend/src/adapters/intl/bigcommerce.live.spec.ts b/packages/backend/src/adapters/intl/bigcommerce.live.spec.ts new file mode 100644 index 0000000..ce647e7 --- /dev/null +++ b/packages/backend/src/adapters/intl/bigcommerce.live.spec.ts @@ -0,0 +1,19 @@ +import * as adapter from './bigcommerce.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: Record }; + tools: Array<{ name: string; endpointMapping: { method: string; path: string } }>; +}; +describe('bigcommerce adapter — static spec conformance', () => { + it('store-hash templated base URL (without version suffix)', () => { + expect(a.connector.baseUrl).toBe('https://api.bigcommerce.com/stores/{{BIGCOMMERCE_STORE_HASH}}'); + }); + it('X-Auth-Token header', () => { + expect(a.connector.authType).toBe('API_KEY'); + expect(a.connector.authConfig.headerName).toBe('X-Auth-Token'); + }); + it('paths explicitly carry /v2 or /v3 prefix', () => { + for (const t of a.tools) { + expect(t.endpointMapping.path.startsWith('/v2/') || t.endpointMapping.path.startsWith('/v3/')).toBe(true); + } + }); +}); diff --git a/packages/backend/src/adapters/intl/drip.json b/packages/backend/src/adapters/intl/drip.json new file mode 100644 index 0000000..41fe0c5 --- /dev/null +++ b/packages/backend/src/adapters/intl/drip.json @@ -0,0 +1,222 @@ +{ + "slug": "drip", + "name": "Drip", + "description": "Drive Drip (e-commerce-focused email + SMS automation) from any AI agent: subscribers, tags, campaigns, events, orders. 12 tools, Basic-auth + account_id in URL.", + "instructions": "This connector uses the Drip REST API v2 (developer.drip.com).\n\n**Setup**:\n1. Sign in to https://www.getdrip.com → top-right avatar → **My User Settings → API → API Token**.\n2. Note your **Account ID** (visible in the URL, e.g. `getdrip.com/9876543/...` → 9876543).\n3. Set:\n - `DRIP_API_TOKEN` = the API token\n - `DRIP_ACCOUNT_ID` = numeric account ID\n\n**Authentication**: HTTP Basic with username=API_TOKEN, password='' (empty). Adapter uses BASIC_AUTH with empty password.\n\n**Account-scoped URLs**: every endpoint is prefixed with `/v2/{account_id}/...`. The adapter substitutes via env var.\n\n**Subscriber model**: subscribers are identified by email (primary) or by external `user_id` (your DB's ID). Custom fields are unlimited key/value pairs.\n\n**Events drive workflows**: Drip's selling point is sending behavioral events (`drip_create_event`) that trigger automated workflows you've designed in the UI. Events: 'Placed Order', 'Browsed Category', 'Abandoned Cart', etc.\n\n**Tags**: simpler segmentation primitive. Apply via `drip_tag_subscriber` (POST /subscribers/{email}/tags).\n\n**Pagination**: `?page=N&per_page=M` (max 1000 for subscribers, smaller for others).\n\n**Rate limits**: 3600 req/hour per account on Free, higher on paid. On 429 honor Retry-After.\n\n**Out of scope here**: workflow management (UI-only), broadcasts (UI), conversion tracking goals, A/B testing.", + "region": "intl", + "category": "marketing-automation", + "icon": "drip", + "docsUrl": "https://developer.drip.com/", + "requiredEnvVars": ["DRIP_API_TOKEN", "DRIP_ACCOUNT_ID"], + "connector": { + "name": "Drip v2", + "type": "REST", + "baseUrl": "https://api.getdrip.com/v2/{{DRIP_ACCOUNT_ID}}", + "authType": "BASIC_AUTH", + "authConfig": { + "username": "{{DRIP_API_TOKEN}}", + "password": "" + } + }, + "tools": [ + { + "name": "drip_list_subscribers", + "description": "List subscribers with filters.", + "parameters": { + "type": "object", + "properties": { + "status": { "type": "string", "description": "active, unsubscribed, all." }, + "tags": { "type": "string", "description": "Comma-separated tag names." }, + "subscribed_before": { "type": "string", "description": "ISO 8601." }, + "subscribed_after": { "type": "string", "description": "ISO 8601." }, + "page": { "type": "integer", "description": "Page (1-based)." }, + "per_page": { "type": "integer", "description": "Per page (max 1000)." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/subscribers", + "queryParams": { + "status": "$status", + "tags": "$tags", + "subscribed_before": "$subscribed_before", + "subscribed_after": "$subscribed_after", + "page": "$page", + "per_page": "$per_page" + } + } + }, + { + "name": "drip_get_subscriber", + "description": "Fetch subscriber by ID OR email (URL-encoded). Returns all fields including tags, custom_fields.", + "parameters": { + "type": "object", + "properties": { + "identifier": { "type": "string", "description": "Subscriber ID or URL-encoded email." } + }, + "required": ["identifier"] + }, + "endpointMapping": { "method": "GET", "path": "/subscribers/{identifier}" } + }, + { + "name": "drip_create_or_update_subscriber", + "description": "Upsert subscriber by email. Drip auto-creates if missing, updates if exists.", + "parameters": { + "type": "object", + "properties": { + "subscribers": { + "type": "array", + "description": "[{email, user_id?, first_name?, last_name?, status?:'active'|'unsubscribed', address1?, city?, state?, country?, postal_code?, time_zone?, lifetime_value?, custom_fields?:{...}, tags?:[], remove_tags?:[]}]. Wrap inside array." + } + }, + "required": ["subscribers"] + }, + "endpointMapping": { + "method": "POST", + "path": "/subscribers", + "bodyMapping": { "subscribers": "$subscribers" } + } + }, + { + "name": "drip_unsubscribe", + "description": "Unsubscribe by email or ID.", + "parameters": { + "type": "object", + "properties": { + "identifier": { "type": "string", "description": "ID or URL-encoded email." } + }, + "required": ["identifier"] + }, + "endpointMapping": { + "method": "POST", + "path": "/subscribers/{identifier}/unsubscribe_all" + } + }, + { + "name": "drip_tag_subscriber", + "description": "Add a single tag to a subscriber.", + "parameters": { + "type": "object", + "properties": { + "tags": { + "type": "array", + "description": "[{email, tag:'TagName'}]." + } + }, + "required": ["tags"] + }, + "endpointMapping": { + "method": "POST", + "path": "/tags", + "bodyMapping": { "tags": "$tags" } + } + }, + { + "name": "drip_remove_tag", + "description": "Remove a tag from a subscriber.", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "URL-encoded email." }, + "tag": { "type": "string", "description": "Tag name." } + }, + "required": ["email", "tag"] + }, + "endpointMapping": { + "method": "DELETE", + "path": "/subscribers/{email}/tags/{tag}" + } + }, + { + "name": "drip_create_event", + "description": "Send a behavioral event for a subscriber. Triggers Drip workflows. Common: 'Placed Order', 'Browsed Category', 'Watched Video'.", + "parameters": { + "type": "object", + "properties": { + "events": { + "type": "array", + "description": "[{email OR user_id, action:'EventName', properties?:{...}, occurred_at?:ISO 8601}]. Wrap inside array." + } + }, + "required": ["events"] + }, + "endpointMapping": { + "method": "POST", + "path": "/events", + "bodyMapping": { "events": "$events" } + } + }, + { + "name": "drip_list_campaigns", + "description": "List campaigns (active workflows / autoresponders). Each has id, name, status, from_email, from_name.", + "parameters": { + "type": "object", + "properties": { + "status": { "type": "string", "description": "active, draft, paused, all." }, + "page": { "type": "integer", "description": "Page." }, + "per_page": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/campaigns", + "queryParams": { + "status": "$status", + "page": "$page", + "per_page": "$per_page" + } + } + }, + { + "name": "drip_subscribe_to_campaign", + "description": "Subscribe a contact to a campaign (enrolls in the workflow).", + "parameters": { + "type": "object", + "properties": { + "campaignId": { "type": "integer", "description": "Campaign ID." }, + "subscribers": { + "type": "array", + "description": "[{email, double_opt_in?:false, reactivate_if_removed?:true, ...subscriber fields...}]." + } + }, + "required": ["campaignId", "subscribers"] + }, + "endpointMapping": { + "method": "POST", + "path": "/campaigns/{campaignId}/subscribers", + "bodyMapping": { "subscribers": "$subscribers" } + } + }, + { + "name": "drip_record_order", + "description": "Record an e-commerce order (used by Drip for revenue attribution and shopper segmentation). Required: order with email + provider + identifier.", + "parameters": { + "type": "object", + "properties": { + "orders": { + "type": "array", + "description": "[{email, provider:'shopify'|'woocommerce'|..., identifier:'#1001', amount:9999 (cents), items:[{product_id, sku, name, quantity, price, ...}], occurred_at?:ISO, currency?:'USD'}]." + } + }, + "required": ["orders"] + }, + "endpointMapping": { + "method": "POST", + "path": "/orders", + "bodyMapping": { "orders": "$orders" } + } + }, + { + "name": "drip_list_tags", + "description": "List all tags in the account with usage counts.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/tags" } + }, + { + "name": "drip_list_custom_fields", + "description": "List custom field names defined on subscribers.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/custom_field_identifiers" } + } + ] +} diff --git a/packages/backend/src/adapters/intl/drip.live.spec.ts b/packages/backend/src/adapters/intl/drip.live.spec.ts new file mode 100644 index 0000000..012f4ed --- /dev/null +++ b/packages/backend/src/adapters/intl/drip.live.spec.ts @@ -0,0 +1,11 @@ +import * as adapter from './drip.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string; authConfig: Record } }; +describe('drip adapter — static spec conformance', () => { + it('account-templated base URL', () => { + expect(a.connector.baseUrl).toBe('https://api.getdrip.com/v2/{{DRIP_ACCOUNT_ID}}'); + }); + it('Basic with token as username and empty password', () => { + expect(a.connector.authConfig.username).toBe('{{DRIP_API_TOKEN}}'); + expect(a.connector.authConfig.password).toBe(''); + }); +}); diff --git a/packages/backend/src/adapters/intl/fathom.json b/packages/backend/src/adapters/intl/fathom.json new file mode 100644 index 0000000..8f793b0 --- /dev/null +++ b/packages/backend/src/adapters/intl/fathom.json @@ -0,0 +1,149 @@ +{ + "slug": "fathom", + "name": "Fathom Analytics", + "description": "Read Fathom Analytics (privacy-friendly web analytics) from any AI agent: aggregations, pageviews, events, sites, custom domains. 6 tools, Bearer auth.", + "instructions": "This connector uses the Fathom Analytics API v1 (usefathom.com/api).\n\n**Setup**:\n1. Sign in to https://app.usefathom.com → top-right avatar → **Account → API Keys → Generate a new API key**.\n2. Pick scopes: at minimum 'sites:read' + 'aggregations:read'.\n3. Set `FATHOM_API_TOKEN`.\n\n**Authentication**: `Authorization: Bearer ${FATHOM_API_TOKEN}`.\n\n**Aggregations API**: the most useful — flexible querying like Stripe Sigma. Pass `entity`, `entity_id`, `aggregates`, `date_grouping`, filters, etc. Returns rows of data.\n\n**Common queries**:\n - Pageviews this week, by day: aggregates=`pageviews,visits,uniques` + date_from=`2025-01-01` + date_grouping=`day`\n - Top pages: aggregates=`pageviews,uniques` + field_grouping=`pathname` + sort_by=`pageviews:desc`\n - Top referrers: aggregates=`pageviews,uniques` + field_grouping=`referrer_hostname` + sort_by=`pageviews:desc`\n - Events conversion: entity=`event` + entity_id=YOUR_EVENT_ID + aggregates=`conversions,uniques,value`\n\n**Rate limits**: 240 req/min per token. On 429 back off.\n\n**Out of scope here**: site CRUD (Fathom limits this), event CRUD beyond list, account billing.", + "region": "intl", + "category": "analytics", + "icon": "fathom", + "docsUrl": "https://usefathom.com/api", + "requiredEnvVars": ["FATHOM_API_TOKEN"], + "connector": { + "name": "Fathom Analytics v1", + "type": "REST", + "baseUrl": "https://api.usefathom.com/v1", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{FATHOM_API_TOKEN}}" + } + }, + "tools": [ + { + "name": "fathom_current_user", + "description": "Return account info: id, name, email.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/account" } + }, + { + "name": "fathom_list_sites", + "description": "List Fathom sites tracked. Each has id, name, sharing setting, created_at.", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Per page (max 100)." }, + "starting_after": { "type": "string", "description": "Cursor — next page." }, + "ending_before": { "type": "string", "description": "Cursor — prev page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/sites", + "queryParams": { + "limit": "$limit", + "starting_after": "$starting_after", + "ending_before": "$ending_before" + } + } + }, + { + "name": "fathom_list_events", + "description": "List events (conversion goals) for a site. Each has id, object, name, currency, target_conversion_value, etc.", + "parameters": { + "type": "object", + "properties": { + "site_id": { "type": "string", "description": "Site ID." }, + "limit": { "type": "integer", "description": "Per page." }, + "starting_after": { "type": "string", "description": "Cursor." } + }, + "required": ["site_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/sites/{site_id}/events", + "queryParams": { + "limit": "$limit", + "starting_after": "$starting_after" + } + } + }, + { + "name": "fathom_aggregations", + "description": "Run an aggregation query — Fathom's main analytics endpoint. See instructions for common query patterns.", + "parameters": { + "type": "object", + "properties": { + "entity": { "type": "string", "description": "pageview, event." }, + "entity_id": { "type": "string", "description": "Site ID (for pageview) OR event ID." }, + "aggregates": { "type": "string", "description": "Comma-separated: pageviews, visits, uniques, avg_duration, bounce_rate, conversions, value, etc." }, + "date_grouping": { "type": "string", "description": "hour, day, month, year." }, + "field_grouping": { "type": "string", "description": "Group by field: pathname, referrer_hostname, country, browser, device_type, hostname, utm_source, utm_medium, utm_campaign, utm_term, utm_content." }, + "filters": { "type": "string", "description": "JSON-stringified filter array: [{property:'pathname', operator:'is', value:'/blog'}]." }, + "sort_by": { "type": "string", "description": "Field:asc|desc, e.g. 'pageviews:desc'." }, + "limit": { "type": "integer", "description": "Max rows." }, + "date_from": { "type": "string", "description": "ISO 8601 date." }, + "date_to": { "type": "string", "description": "ISO 8601 date." }, + "timezone": { "type": "string", "description": "TZ database name." } + }, + "required": ["entity", "entity_id", "aggregates"] + }, + "endpointMapping": { + "method": "GET", + "path": "/aggregations", + "queryParams": { + "entity": "$entity", + "entity_id": "$entity_id", + "aggregates": "$aggregates", + "date_grouping": "$date_grouping", + "field_grouping": "$field_grouping", + "filters": "$filters", + "sort_by": "$sort_by", + "limit": "$limit", + "date_from": "$date_from", + "date_to": "$date_to", + "timezone": "$timezone" + } + } + }, + { + "name": "fathom_current_visitors", + "description": "Get the current number of visitors on a site (real-time).", + "parameters": { + "type": "object", + "properties": { + "site_id": { "type": "string", "description": "Site ID." }, + "detailed": { "type": "boolean", "description": "If true, also return arrays of currently-viewed pathnames + countries." } + }, + "required": ["site_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/current_visitors", + "queryParams": { + "site_id": "$site_id", + "detailed": "$detailed" + } + } + }, + { + "name": "fathom_create_event", + "description": "Create a new event (conversion goal) for a site.", + "parameters": { + "type": "object", + "properties": { + "site_id": { "type": "string", "description": "Site ID." }, + "name": { "type": "string", "description": "Event name." }, + "currency": { "type": "string", "description": "ISO 4217 currency for event value tracking." } + }, + "required": ["site_id", "name"] + }, + "endpointMapping": { + "method": "POST", + "path": "/sites/{site_id}/events", + "bodyMapping": { + "name": "$name", + "currency": "$currency" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/fathom.live.spec.ts b/packages/backend/src/adapters/intl/fathom.live.spec.ts new file mode 100644 index 0000000..3696438 --- /dev/null +++ b/packages/backend/src/adapters/intl/fathom.live.spec.ts @@ -0,0 +1,6 @@ +import * as adapter from './fathom.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string; authConfig: Record } }; +describe('fathom adapter — static spec conformance', () => { + it('api.usefathom.com/v1', () => expect(a.connector.baseUrl).toBe('https://api.usefathom.com/v1')); + it('Bearer auth', () => expect(a.connector.authType).toBe('BEARER_TOKEN')); +}); diff --git a/packages/backend/src/adapters/intl/heap.json b/packages/backend/src/adapters/intl/heap.json new file mode 100644 index 0000000..7a40192 --- /dev/null +++ b/packages/backend/src/adapters/intl/heap.json @@ -0,0 +1,133 @@ +{ + "slug": "heap", + "name": "Heap", + "description": "Send events, identify users and update properties in Heap (product analytics) from any AI agent. 5 tools, app_id-based auth.", + "instructions": "This connector uses the Heap Server-Side API (developers.heap.io/docs/server-side-apis).\n\n**Setup**:\n1. Sign in to https://app.heap.io → top-right → **Account → Projects → select project → Project ID** (called `app_id`).\n2. Set `HEAP_APP_ID` to the numeric app/project ID.\n\n**Authentication**: NONE — Heap's server-side API uses the app_id in the request body to route events to your project. There's no secret on the ingestion endpoint (anyone with your app_id can fake events — same trust model as Google Analytics client-side JS).\n\n**identity vs user_id**: every event references a user via `identity` (a stable string you control — typically your user's UUID or email). Heap merges anonymous sessions to identified users on first identify call with the same identity.\n\n**Property types**: properties (event-level OR user-level) are arbitrary key-value pairs. Max 1024 properties per event, max 1MB total request size.\n\n**Out of scope here**: Read APIs (the Heap Read API requires a separate auth and is gated to higher plans), funnel/segment queries, account/billing.", + "region": "intl", + "category": "analytics", + "icon": "heap", + "docsUrl": "https://developers.heap.io/reference/server-side-apis-overview", + "requiredEnvVars": ["HEAP_APP_ID"], + "connector": { + "name": "Heap Server-Side", + "type": "REST", + "baseUrl": "https://heapanalytics.com/api", + "authType": "NONE" + }, + "tools": [ + { + "name": "heap_track_event", + "description": "Track a single behavioral event for a user. Required: app_id + identity + event. properties are arbitrary key/value.", + "parameters": { + "type": "object", + "properties": { + "app_id": { "type": "string", "description": "Heap app/project ID (use HEAP_APP_ID env var)." }, + "identity": { "type": "string", "description": "Stable user identifier (your UUID or email)." }, + "event": { "type": "string", "description": "Event name (e.g. 'Purchase Completed')." }, + "properties": { "type": "object", "description": "Event properties (any keys + values)." }, + "idempotency_key": { "type": "string", "description": "Optional dedupe key — Heap drops duplicates within ~24h." }, + "timestamp": { "type": "string", "description": "ISO 8601 — defaults to now." } + }, + "required": ["app_id", "identity", "event"] + }, + "endpointMapping": { + "method": "POST", + "path": "/track", + "bodyMapping": { + "app_id": "$app_id", + "identity": "$identity", + "event": "$event", + "properties": "$properties", + "idempotency_key": "$idempotency_key", + "timestamp": "$timestamp" + } + } + }, + { + "name": "heap_bulk_track", + "description": "Bulk-track many events in one call (up to 1000 events, 1MB total).", + "parameters": { + "type": "object", + "properties": { + "app_id": { "type": "string", "description": "Heap app ID." }, + "events": { "type": "array", "description": "Array of event objects: [{identity, event, properties?, idempotency_key?, timestamp?}, ...]." } + }, + "required": ["app_id", "events"] + }, + "endpointMapping": { + "method": "POST", + "path": "/track", + "bodyMapping": { + "app_id": "$app_id", + "events": "$events" + } + } + }, + { + "name": "heap_add_user_properties", + "description": "Set or update user-level properties (Heap merges with existing). User properties persist across all events for that identity.", + "parameters": { + "type": "object", + "properties": { + "app_id": { "type": "string", "description": "Heap app ID." }, + "identity": { "type": "string", "description": "User identifier." }, + "properties": { "type": "object", "description": "User properties to set/merge." } + }, + "required": ["app_id", "identity", "properties"] + }, + "endpointMapping": { + "method": "POST", + "path": "/add_user_properties", + "bodyMapping": { + "app_id": "$app_id", + "identity": "$identity", + "properties": "$properties" + } + } + }, + { + "name": "heap_add_account_properties", + "description": "Set or update properties on an account (B2B model — accounts group users). Required: account_id.", + "parameters": { + "type": "object", + "properties": { + "app_id": { "type": "string", "description": "Heap app ID." }, + "account_id": { "type": "string", "description": "Account identifier (your tenant/org ID)." }, + "properties": { "type": "object", "description": "Account properties." } + }, + "required": ["app_id", "account_id", "properties"] + }, + "endpointMapping": { + "method": "POST", + "path": "/add_account_properties", + "bodyMapping": { + "app_id": "$app_id", + "account_id": "$account_id", + "properties": "$properties" + } + } + }, + { + "name": "heap_add_user_to_account", + "description": "Associate a user (identity) with an account (account_id). Required: app_id + identity + account_id.", + "parameters": { + "type": "object", + "properties": { + "app_id": { "type": "string", "description": "Heap app ID." }, + "identity": { "type": "string", "description": "User identifier." }, + "account_id": { "type": "string", "description": "Account ID." } + }, + "required": ["app_id", "identity", "account_id"] + }, + "endpointMapping": { + "method": "POST", + "path": "/add_user_to_account", + "bodyMapping": { + "app_id": "$app_id", + "identity": "$identity", + "account_id": "$account_id" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/heap.live.spec.ts b/packages/backend/src/adapters/intl/heap.live.spec.ts new file mode 100644 index 0000000..4001b60 --- /dev/null +++ b/packages/backend/src/adapters/intl/heap.live.spec.ts @@ -0,0 +1,6 @@ +import * as adapter from './heap.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string } }; +describe('heap adapter — static spec conformance', () => { + it('heapanalytics.com/api', () => expect(a.connector.baseUrl).toBe('https://heapanalytics.com/api')); + it('NONE auth (app_id is in request body, not header)', () => expect(a.connector.authType).toBe('NONE')); +}); From 209d0b27629b1be85eb683325765443ed003db27 Mon Sep 17 00:00:00 2001 From: Matteo Date: Wed, 20 May 2026 13:06:02 +0200 Subject: [PATCH 12/19] connectors: add Chargebee, Recurly, Crisp, Adyen, Statsig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 9 — payments + support + feature flags. - Chargebee v2: 14 tools — customers, subscriptions with item-prices, pause/resume, invoices, items, item_prices, coupons. BASIC_AUTH. Site-templated baseUrl. form-urlencoded bodies (Chargebee convention). - Recurly v2021-02-25: 11 tools — accounts, subscriptions with cancel/terminate, invoices, plans, coupons. BASIC_AUTH. Required vendor-specific Accept header pinned per-tool. - Crisp v1: 11 tools — conversations CRUD with state transitions and routing, messages with internal-note support, people profiles, operators. Website ID baked into baseUrl. X-Crisp-Tier: plugin header on every request (Crisp requirement). - Adyen Checkout v71: 10 tools — paymentMethods discovery, create payment, payments/details for 3DS finalization, capture, refund, cancel, reversal, sessions, origin keys. X-API-Key header. Test environment by default. - Statsig: 10 tools — Server SDK (check_gate, get_config, get_experiment, initialize, log_event) + Console (list/create gates, list/get experiment results). Single adapter spans TWO distinct API hosts via per-tool absolute URLs. Engine change: RestEngine now allows endpointMapping.path to be a full absolute URL — bypasses baseUrl when the path starts with http(s)://. Lets adapters cover multi-host vendors (Statsig) without splitting into two connector records. Backward compatible. Catalog: 90 adapters (50/81 of the greenfield batch done, ~62%). --- packages/backend/src/adapters/catalog.ts | 10 + packages/backend/src/adapters/intl/adyen.json | 264 ++++++++++++++ .../src/adapters/intl/adyen.live.spec.ts | 9 + .../backend/src/adapters/intl/chargebee.json | 337 ++++++++++++++++++ .../src/adapters/intl/chargebee.live.spec.ts | 12 + packages/backend/src/adapters/intl/crisp.json | 251 +++++++++++++ .../src/adapters/intl/crisp.live.spec.ts | 20 ++ .../backend/src/adapters/intl/recurly.json | 291 +++++++++++++++ .../src/adapters/intl/recurly.live.spec.ts | 17 + .../backend/src/adapters/intl/statsig.json | 215 +++++++++++ .../src/adapters/intl/statsig.live.spec.ts | 15 + .../src/connectors/engines/rest.engine.ts | 6 +- 12 files changed, 1446 insertions(+), 1 deletion(-) create mode 100644 packages/backend/src/adapters/intl/adyen.json create mode 100644 packages/backend/src/adapters/intl/adyen.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/chargebee.json create mode 100644 packages/backend/src/adapters/intl/chargebee.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/crisp.json create mode 100644 packages/backend/src/adapters/intl/crisp.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/recurly.json create mode 100644 packages/backend/src/adapters/intl/recurly.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/statsig.json create mode 100644 packages/backend/src/adapters/intl/statsig.live.spec.ts diff --git a/packages/backend/src/adapters/catalog.ts b/packages/backend/src/adapters/catalog.ts index e924087..9986100 100644 --- a/packages/backend/src/adapters/catalog.ts +++ b/packages/backend/src/adapters/catalog.ts @@ -33,16 +33,19 @@ import * as companiesHouse from './gb/companies-house.json'; import * as wise from './gb/wise.json'; import * as activecampaign from './intl/activecampaign.json'; import * as acuityScheduling from './intl/acuity-scheduling.json'; +import * as adyen from './intl/adyen.json'; import * as apollo from './intl/apollo.json'; import * as basecamp from './intl/basecamp.json'; import * as bigcommerce from './intl/bigcommerce.json'; import * as brevo from './intl/brevo.json'; import * as calendly from './intl/calendly.json'; +import * as chargebee from './intl/chargebee.json'; import * as clickup from './intl/clickup.json'; import * as close from './intl/close.json'; import * as coda from './intl/coda.json'; import * as convertkit from './intl/convertkit.json'; import * as copper from './intl/copper.json'; +import * as crisp from './intl/crisp.json'; import * as discordBot from './intl/discord-bot.json'; import * as drip from './intl/drip.json'; import * as fathom from './intl/fathom.json'; @@ -65,10 +68,12 @@ import * as nominatim from './intl/nominatim.json'; import * as outreach from './intl/outreach.json'; import * as pandadoc from './intl/pandadoc.json'; import * as pipedrive from './intl/pipedrive.json'; +import * as recurly from './intl/recurly.json'; import * as reddit from './intl/reddit.json'; import * as salesloft from './intl/salesloft.json'; import * as sendgrid from './intl/sendgrid.json'; import * as sorare from './intl/sorare.json'; +import * as statsig from './intl/statsig.json'; import * as substack from './intl/substack.json'; import * as surveymonkey from './intl/surveymonkey.json'; import * as tally from './intl/tally.json'; @@ -190,16 +195,19 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ wise as unknown as AdapterDefinition, activecampaign as unknown as AdapterDefinition, acuityScheduling as unknown as AdapterDefinition, + adyen as unknown as AdapterDefinition, apollo as unknown as AdapterDefinition, basecamp as unknown as AdapterDefinition, bigcommerce as unknown as AdapterDefinition, brevo as unknown as AdapterDefinition, calendly as unknown as AdapterDefinition, + chargebee as unknown as AdapterDefinition, clickup as unknown as AdapterDefinition, close as unknown as AdapterDefinition, coda as unknown as AdapterDefinition, convertkit as unknown as AdapterDefinition, copper as unknown as AdapterDefinition, + crisp as unknown as AdapterDefinition, discordBot as unknown as AdapterDefinition, drip as unknown as AdapterDefinition, fathom as unknown as AdapterDefinition, @@ -222,10 +230,12 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ outreach as unknown as AdapterDefinition, pandadoc as unknown as AdapterDefinition, pipedrive as unknown as AdapterDefinition, + recurly as unknown as AdapterDefinition, reddit as unknown as AdapterDefinition, salesloft as unknown as AdapterDefinition, sendgrid as unknown as AdapterDefinition, sorare as unknown as AdapterDefinition, + statsig as unknown as AdapterDefinition, substack as unknown as AdapterDefinition, surveymonkey as unknown as AdapterDefinition, tally as unknown as AdapterDefinition, diff --git a/packages/backend/src/adapters/intl/adyen.json b/packages/backend/src/adapters/intl/adyen.json new file mode 100644 index 0000000..88aa1db --- /dev/null +++ b/packages/backend/src/adapters/intl/adyen.json @@ -0,0 +1,264 @@ +{ + "slug": "adyen", + "name": "Adyen", + "description": "Drive Adyen (global payment processor) checkout + payments + balance platform from any AI agent. 10 tools, API-key auth, separate live and test environments.", + "instructions": "This connector uses the Adyen Checkout API v71 and Payments Modifications API (docs.adyen.com).\n\n**Setup**:\n1. Sign in to https://ca-test.adyen.com (test) or https://ca-live.adyen.com (live) → **Developers → API credentials → New credential → API user**.\n2. Generate the API key. Note your **merchant account name** (visible in top dropdown, e.g. `AcmeCo`).\n3. Set:\n - `ADYEN_API_KEY` = the API key\n - `ADYEN_MERCHANT_ACCOUNT` = the merchant account name\n - `ADYEN_ENV` = `test` or `live`\n - For LIVE only: also set `ADYEN_LIVE_URL_PREFIX` = the URL prefix Adyen issued you (something like `abc123def-AcmeCo`). Find in CA → Developers → API URLs.\n\n**Authentication**: header `X-API-Key: ${ADYEN_API_KEY}`.\n\n**Base URL switches environment**: Adyen test → `https://checkout-test.adyen.com/v71`. Live → `https://{PREFIX}-checkout-live.adyenpayments.com/checkout/v71`. The adapter uses test by default; for live, point `ADYEN_CHECKOUT_BASE_URL` env var at the prefixed live URL.\n\n**Amount format**: `{currency: 'EUR', value: 1099}` — value in MINOR units (cents). EUR uses 2 decimals, JPY 0 decimals — Adyen handles per ISO 4217. NEVER pass decimals.\n\n**Idempotency**: pass `Idempotency-Key` header on POST `/payments` to dedupe retries.\n\n**Payment flow**:\n 1. `adyen_payment_methods` — get available methods for currency/country/amount.\n 2. `adyen_create_payment` — create the payment. Response includes `resultCode` (Authorised/Refused/Pending/RedirectShopper/etc.) and optional `action` (redirect/3DS challenge).\n 3. If action: shopper completes it client-side; server gets back `details` to submit.\n 4. `adyen_payments_details` — finalize.\n 5. Track via `adyen_payments_capture` (manual capture flow), `adyen_payments_refund`, `adyen_payments_cancel`.\n\n**Modifications**: capture/refund/cancel use the dedicated modification endpoints, NOT the same endpoint as create.\n\n**Webhooks** out of scope.\n\n**Out of scope here**: Terminal API (in-person), Balance Platform deep features, Marketpay, Recurring API beyond basic.", + "region": "intl", + "category": "payments", + "icon": "adyen", + "docsUrl": "https://docs.adyen.com/api-explorer/", + "requiredEnvVars": ["ADYEN_API_KEY", "ADYEN_MERCHANT_ACCOUNT"], + "connector": { + "name": "Adyen Checkout v71", + "type": "REST", + "baseUrl": "https://checkout-test.adyen.com/v71", + "authType": "API_KEY", + "authConfig": { + "headerName": "X-API-Key", + "apiKey": "{{ADYEN_API_KEY}}" + } + }, + "tools": [ + { + "name": "adyen_payment_methods", + "description": "List available payment methods for a given currency / country / amount. Returns the methods array that the Adyen Drop-in/Components SDKs render.", + "parameters": { + "type": "object", + "properties": { + "merchantAccount": { "type": "string", "description": "Merchant account name (use ADYEN_MERCHANT_ACCOUNT)." }, + "amount": { "type": "object", "description": "{currency:'EUR', value:1099} — minor units." }, + "countryCode": { "type": "string", "description": "ISO 3166-1 alpha-2 country (e.g. 'DE', 'US')." }, + "shopperLocale": { "type": "string", "description": "Locale (e.g. 'en-US', 'de-DE')." }, + "channel": { "type": "string", "description": "iOS, Android, Web." }, + "shopperReference": { "type": "string", "description": "Shopper unique ID — required for showing stored payment methods." } + }, + "required": ["merchantAccount"] + }, + "endpointMapping": { + "method": "POST", + "path": "/paymentMethods", + "bodyMapping": { + "merchantAccount": "$merchantAccount", + "amount": "$amount", + "countryCode": "$countryCode", + "shopperLocale": "$shopperLocale", + "channel": "$channel", + "shopperReference": "$shopperReference" + } + } + }, + { + "name": "adyen_create_payment", + "description": "Create a payment. paymentMethod object varies by method type (card, ideal, paypal, etc.) — usually you get an opaque `paymentMethod` object from the client-side Drop-in. Returns resultCode + action (if 3DS/redirect needed).", + "parameters": { + "type": "object", + "properties": { + "merchantAccount": { "type": "string", "description": "Merchant account name." }, + "amount": { "type": "object", "description": "{currency, value (minor units)}." }, + "reference": { "type": "string", "description": "Your unique reference for this payment (shown on reports)." }, + "paymentMethod": { "type": "object", "description": "Method-specific payload, e.g. {type:'scheme', encryptedCardNumber:..., encryptedExpiryMonth:..., encryptedExpiryYear:..., encryptedSecurityCode:..., holderName:...}. From Drop-in/Components." }, + "returnUrl": { "type": "string", "description": "Where to return shopper after redirect-based payments (3DS, iDEAL)." }, + "shopperReference": { "type": "string", "description": "Stable shopper ID for storing payment methods." }, + "shopperEmail": { "type": "string", "description": "Email." }, + "shopperIP": { "type": "string", "description": "Shopper IP (for risk scoring)." }, + "shopperInteraction": { "type": "string", "description": "Ecommerce (default) or ContAuth (recurring)." }, + "recurringProcessingModel": { "type": "string", "description": "CardOnFile, Subscription, UnscheduledCardOnFile — required for storing payment methods." }, + "storePaymentMethod": { "type": "boolean", "description": "If true and shopperReference is set, store the payment method for future use." }, + "billingAddress": { "type": "object", "description": "{street, houseNumberOrName, postalCode, city, country, stateOrProvince?}." }, + "channel": { "type": "string", "description": "Web, iOS, Android." } + }, + "required": ["merchantAccount", "amount", "reference", "paymentMethod"] + }, + "endpointMapping": { + "method": "POST", + "path": "/payments", + "bodyMapping": { + "merchantAccount": "$merchantAccount", + "amount": "$amount", + "reference": "$reference", + "paymentMethod": "$paymentMethod", + "returnUrl": "$returnUrl", + "shopperReference": "$shopperReference", + "shopperEmail": "$shopperEmail", + "shopperIP": "$shopperIP", + "shopperInteraction": "$shopperInteraction", + "recurringProcessingModel": "$recurringProcessingModel", + "storePaymentMethod": "$storePaymentMethod", + "billingAddress": "$billingAddress", + "channel": "$channel" + } + } + }, + { + "name": "adyen_payments_details", + "description": "Finalize a payment with the action result (3DS challenge result, redirect result, etc.). Called after the shopper completes the action returned by create_payment.", + "parameters": { + "type": "object", + "properties": { + "details": { "type": "object", "description": "Action-specific details (e.g. {redirectResult:'...'} for redirect, {threeDSResult:'...'} for 3DS1)." }, + "paymentData": { "type": "string", "description": "paymentData string from the create response." } + }, + "required": ["details"] + }, + "endpointMapping": { + "method": "POST", + "path": "/payments/details", + "bodyMapping": { + "details": "$details", + "paymentData": "$paymentData" + } + } + }, + { + "name": "adyen_payments_capture", + "description": "Capture an authorised payment (manual-capture flow). For instant capture, set captureDelayHours=0 on create_payment instead.", + "parameters": { + "type": "object", + "properties": { + "paymentPspReference": { "type": "string", "description": "The pspReference from a prior successful payment authorisation." }, + "merchantAccount": { "type": "string", "description": "Merchant account name." }, + "amount": { "type": "object", "description": "{currency, value} — must match authorisation currency. Smaller value = partial capture." }, + "reference": { "type": "string", "description": "Your reference for the capture." } + }, + "required": ["paymentPspReference", "merchantAccount", "amount"] + }, + "endpointMapping": { + "method": "POST", + "path": "/payments/{paymentPspReference}/captures", + "bodyMapping": { + "merchantAccount": "$merchantAccount", + "amount": "$amount", + "reference": "$reference" + } + } + }, + { + "name": "adyen_payments_refund", + "description": "Refund a captured payment. Partial refunds OK (smaller amount).", + "parameters": { + "type": "object", + "properties": { + "paymentPspReference": { "type": "string", "description": "Original payment pspReference." }, + "merchantAccount": { "type": "string", "description": "Merchant account." }, + "amount": { "type": "object", "description": "{currency, value}." }, + "reference": { "type": "string", "description": "Your refund reference." } + }, + "required": ["paymentPspReference", "merchantAccount", "amount"] + }, + "endpointMapping": { + "method": "POST", + "path": "/payments/{paymentPspReference}/refunds", + "bodyMapping": { + "merchantAccount": "$merchantAccount", + "amount": "$amount", + "reference": "$reference" + } + } + }, + { + "name": "adyen_payments_cancel", + "description": "Cancel an authorisation that hasn't been captured yet.", + "parameters": { + "type": "object", + "properties": { + "paymentPspReference": { "type": "string", "description": "Payment pspReference." }, + "merchantAccount": { "type": "string", "description": "Merchant account." }, + "reference": { "type": "string", "description": "Your reference." } + }, + "required": ["paymentPspReference", "merchantAccount"] + }, + "endpointMapping": { + "method": "POST", + "path": "/payments/{paymentPspReference}/cancels", + "bodyMapping": { + "merchantAccount": "$merchantAccount", + "reference": "$reference" + } + } + }, + { + "name": "adyen_payments_reversal", + "description": "Reverse an authorisation (handles capture + refund as one atomic op if needed).", + "parameters": { + "type": "object", + "properties": { + "paymentPspReference": { "type": "string", "description": "Payment pspReference." }, + "merchantAccount": { "type": "string", "description": "Merchant account." }, + "reference": { "type": "string", "description": "Your reference." } + }, + "required": ["paymentPspReference", "merchantAccount"] + }, + "endpointMapping": { + "method": "POST", + "path": "/payments/{paymentPspReference}/reversals", + "bodyMapping": { + "merchantAccount": "$merchantAccount", + "reference": "$reference" + } + } + }, + { + "name": "adyen_sessions_create", + "description": "Create a payment session (used by Drop-in v5+). Returns a session ID + sessionData the client uses to initialise the SDK.", + "parameters": { + "type": "object", + "properties": { + "merchantAccount": { "type": "string", "description": "Merchant account." }, + "amount": { "type": "object", "description": "{currency, value}." }, + "reference": { "type": "string", "description": "Your unique reference." }, + "returnUrl": { "type": "string", "description": "Return URL." }, + "countryCode": { "type": "string", "description": "ISO country." }, + "shopperLocale": { "type": "string", "description": "Shopper locale." }, + "channel": { "type": "string", "description": "Web, iOS, Android." } + }, + "required": ["merchantAccount", "amount", "reference", "returnUrl"] + }, + "endpointMapping": { + "method": "POST", + "path": "/sessions", + "bodyMapping": { + "merchantAccount": "$merchantAccount", + "amount": "$amount", + "reference": "$reference", + "returnUrl": "$returnUrl", + "countryCode": "$countryCode", + "shopperLocale": "$shopperLocale", + "channel": "$channel" + } + } + }, + { + "name": "adyen_session_get_result", + "description": "Get the result of a session (after the shopper has completed payment in Drop-in).", + "parameters": { + "type": "object", + "properties": { + "sessionId": { "type": "string", "description": "Session ID from sessions_create." }, + "sessionResult": { "type": "string", "description": "sessionResult string from Drop-in's onPaymentCompleted." } + }, + "required": ["sessionId", "sessionResult"] + }, + "endpointMapping": { + "method": "GET", + "path": "/sessions/{sessionId}", + "queryParams": { "sessionResult": "$sessionResult" } + } + }, + { + "name": "adyen_origin_keys", + "description": "Get origin keys for web-Drop-in (deprecated since 2023 in favor of clientKey — included for legacy integrations).", + "parameters": { + "type": "object", + "properties": { + "originDomains": { "type": "array", "description": "Array of URLs (e.g. ['https://shop.acme.com'])." } + }, + "required": ["originDomains"] + }, + "endpointMapping": { + "method": "POST", + "path": "/originKeys", + "bodyMapping": { "originDomains": "$originDomains" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/adyen.live.spec.ts b/packages/backend/src/adapters/intl/adyen.live.spec.ts new file mode 100644 index 0000000..2f44bfa --- /dev/null +++ b/packages/backend/src/adapters/intl/adyen.live.spec.ts @@ -0,0 +1,9 @@ +import * as adapter from './adyen.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string; authConfig: Record } }; +describe('adyen adapter — static spec conformance', () => { + it('test base URL by default', () => expect(a.connector.baseUrl).toBe('https://checkout-test.adyen.com/v71')); + it('X-API-Key header', () => { + expect(a.connector.authType).toBe('API_KEY'); + expect(a.connector.authConfig.headerName).toBe('X-API-Key'); + }); +}); diff --git a/packages/backend/src/adapters/intl/chargebee.json b/packages/backend/src/adapters/intl/chargebee.json new file mode 100644 index 0000000..54d1a8d --- /dev/null +++ b/packages/backend/src/adapters/intl/chargebee.json @@ -0,0 +1,337 @@ +{ + "slug": "chargebee", + "name": "Chargebee", + "description": "Drive Chargebee (subscription billing) from any AI agent: customers, subscriptions, invoices, plans, addons, coupons. 14 tools, Basic-auth, per-site base URL.", + "instructions": "This connector uses the Chargebee API v2 (apidocs.chargebee.com/docs/api).\n\n**Setup**:\n1. Sign in to Chargebee → **Settings → API Keys → + Create a Key**.\n2. Pick a Full-Access key for write operations OR a Restricted Key for read-only.\n3. Note your **site subdomain** (e.g. `acme-test` if your URL is `acme-test.chargebee.com`).\n4. Set:\n - `CHARGEBEE_SITE` = the subdomain (without `.chargebee.com`)\n - `CHARGEBEE_API_KEY` = the full API key (`live_...` or `test_...`)\n\n**Authentication**: HTTP Basic with username=API_KEY, password=empty.\n\n**Site-templated URL**: `https://{{CHARGEBEE_SITE}}.chargebee.com/api/v2`. Test vs Live sites are different subdomains (e.g. `acme-test` vs `acme`) and have different API keys.\n\n**Amount format**: monetary values are integers in MINOR units (cents) — `1000` = $10.00 USD or €10.00 EUR. Match the site's primary currency (or pass `currency_code`).\n\n**Idempotency**: pass `chargebee-idempotency-key` header on writes to dedupe retries. (The adapter exposes this as a regular `idempotency_key` parameter on write tools.)\n\n**Form-encoded requests**: Chargebee historically uses application/x-www-form-urlencoded for POST/PUT bodies (with nested keys as `subscription[plan_id]=...`). The adapter handles this via bodyEncoding=form-urlencoded.\n\n**Subscription lifecycle**: future → in_trial → active → non_renewing → cancelled. Pause via `chargebee_pause_subscription` (returns paused), resume via `chargebee_resume_subscription`.\n\n**Webhooks** out of scope.\n\n**Rate limits**: 750 req per 5 min per site on Standard. On 429 honor X-Rate-Limit-Reset.\n\n**Out of scope here**: hosted pages, portal session, refundable credits, gifts, transactions detail, hierarchies, comments, virtual bank accounts, gateway/payment-source CRUD.", + "region": "intl", + "category": "payments", + "icon": "chargebee", + "docsUrl": "https://apidocs.chargebee.com/docs/api", + "requiredEnvVars": ["CHARGEBEE_SITE", "CHARGEBEE_API_KEY"], + "connector": { + "name": "Chargebee v2", + "type": "REST", + "baseUrl": "https://{{CHARGEBEE_SITE}}.chargebee.com/api/v2", + "authType": "BASIC_AUTH", + "authConfig": { + "username": "{{CHARGEBEE_API_KEY}}", + "password": "" + } + }, + "tools": [ + { + "name": "chargebee_list_customers", + "description": "List customers with filters.", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "Filter by email." }, + "company": { "type": "string", "description": "Filter by company." }, + "first_name": { "type": "string", "description": "First name." }, + "last_name": { "type": "string", "description": "Last name." }, + "auto_collection": { "type": "string", "description": "on or off." }, + "limit": { "type": "integer", "description": "Per page (max 100)." }, + "offset": { "type": "string", "description": "Pagination offset cursor." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/customers", + "queryParams": { + "email[is]": "$email", + "company[is]": "$company", + "first_name[is]": "$first_name", + "last_name[is]": "$last_name", + "auto_collection[is]": "$auto_collection", + "limit": "$limit", + "offset": "$offset" + } + } + }, + { + "name": "chargebee_get_customer", + "description": "Fetch a customer by ID.", + "parameters": { + "type": "object", + "properties": { + "customerId": { "type": "string", "description": "Customer ID (you assign it or Chargebee auto-generates)." } + }, + "required": ["customerId"] + }, + "endpointMapping": { "method": "GET", "path": "/customers/{customerId}" } + }, + { + "name": "chargebee_create_customer", + "description": "Create a customer. id is optional — Chargebee generates one if omitted.", + "parameters": { + "type": "object", + "properties": { + "id": { "type": "string", "description": "Optional customer ID." }, + "first_name": { "type": "string", "description": "First name." }, + "last_name": { "type": "string", "description": "Last name." }, + "email": { "type": "string", "description": "Email." }, + "phone": { "type": "string", "description": "Phone." }, + "company": { "type": "string", "description": "Company." }, + "vat_number": { "type": "string", "description": "VAT number." }, + "auto_collection": { "type": "string", "description": "on (auto-charge cards) or off (manual)." }, + "net_term_days": { "type": "integer", "description": "Days for invoice payment." }, + "allow_direct_debit": { "type": "boolean", "description": "Allow SEPA/Bacs etc." }, + "locale": { "type": "string", "description": "Customer locale (en, de, fr, ...)." }, + "billing_address": { "type": "object", "description": "{first_name?, last_name?, company?, line1, city, state, country, zip, phone?}." } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/customers", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { + "id": "$id", + "first_name": "$first_name", + "last_name": "$last_name", + "email": "$email", + "phone": "$phone", + "company": "$company", + "vat_number": "$vat_number", + "auto_collection": "$auto_collection", + "net_term_days": "$net_term_days", + "allow_direct_debit": "$allow_direct_debit", + "locale": "$locale" + } + } + }, + { + "name": "chargebee_list_subscriptions", + "description": "List subscriptions with filters.", + "parameters": { + "type": "object", + "properties": { + "customer_id": { "type": "string", "description": "Filter by customer." }, + "plan_id": { "type": "string", "description": "Filter by plan." }, + "status": { "type": "string", "description": "future, in_trial, active, non_renewing, cancelled, paused." }, + "limit": { "type": "integer", "description": "Per page (max 100)." }, + "offset": { "type": "string", "description": "Cursor." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/subscriptions", + "queryParams": { + "customer_id[is]": "$customer_id", + "plan_id[is]": "$plan_id", + "status[is]": "$status", + "limit": "$limit", + "offset": "$offset" + } + } + }, + { + "name": "chargebee_get_subscription", + "description": "Fetch a subscription by ID.", + "parameters": { + "type": "object", + "properties": { + "subscriptionId": { "type": "string", "description": "Subscription ID." } + }, + "required": ["subscriptionId"] + }, + "endpointMapping": { "method": "GET", "path": "/subscriptions/{subscriptionId}" } + }, + { + "name": "chargebee_create_subscription_for_customer", + "description": "Create a subscription for an existing customer. Uses 'item-prices' model (Chargebee 2.0+).", + "parameters": { + "type": "object", + "properties": { + "customerId": { "type": "string", "description": "Customer ID." }, + "subscription_items": { + "type": "array", + "description": "Array of items: [{item_price_id:'plan-1-EUR-monthly', quantity?:1, billing_cycles?:N}]. At least one plan item required." + }, + "auto_collection": { "type": "string", "description": "on or off." }, + "po_number": { "type": "string", "description": "Purchase order #." }, + "start_date": { "type": "integer", "description": "Unix timestamp — future start (delay). Omit for now." }, + "trial_end": { "type": "integer", "description": "Unix timestamp end of trial." } + }, + "required": ["customerId", "subscription_items"] + }, + "endpointMapping": { + "method": "POST", + "path": "/customers/{customerId}/subscription_for_items", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { + "subscription_items": "$subscription_items", + "auto_collection": "$auto_collection", + "po_number": "$po_number", + "start_date": "$start_date", + "trial_end": "$trial_end" + } + } + }, + { + "name": "chargebee_cancel_subscription", + "description": "Cancel a subscription. end_of_term=true delays cancellation to current period end.", + "parameters": { + "type": "object", + "properties": { + "subscriptionId": { "type": "string", "description": "Subscription ID." }, + "end_of_term": { "type": "boolean", "description": "If true, cancel at period end (graceful)." }, + "credit_option_for_current_term_charges": { "type": "string", "description": "none, prorate, full." } + }, + "required": ["subscriptionId"] + }, + "endpointMapping": { + "method": "POST", + "path": "/subscriptions/{subscriptionId}/cancel_for_items", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { + "end_of_term": "$end_of_term", + "credit_option_for_current_term_charges": "$credit_option_for_current_term_charges" + } + } + }, + { + "name": "chargebee_pause_subscription", + "description": "Pause a subscription. Specify pause_option (immediately/end_of_term/specific_date).", + "parameters": { + "type": "object", + "properties": { + "subscriptionId": { "type": "string", "description": "Subscription ID." }, + "pause_option": { "type": "string", "description": "immediately, end_of_term, specific_date." }, + "pause_date": { "type": "integer", "description": "Unix timestamp (required if pause_option=specific_date)." }, + "resume_date": { "type": "integer", "description": "Optional auto-resume Unix timestamp." } + }, + "required": ["subscriptionId"] + }, + "endpointMapping": { + "method": "POST", + "path": "/subscriptions/{subscriptionId}/pause", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { + "pause_option": "$pause_option", + "pause_date": "$pause_date", + "resume_date": "$resume_date" + } + } + }, + { + "name": "chargebee_resume_subscription", + "description": "Resume a paused subscription.", + "parameters": { + "type": "object", + "properties": { + "subscriptionId": { "type": "string", "description": "Subscription ID." }, + "resume_option": { "type": "string", "description": "immediately, specific_date." }, + "resume_date": { "type": "integer", "description": "Unix timestamp (required if resume_option=specific_date)." } + }, + "required": ["subscriptionId"] + }, + "endpointMapping": { + "method": "POST", + "path": "/subscriptions/{subscriptionId}/resume", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { + "resume_option": "$resume_option", + "resume_date": "$resume_date" + } + } + }, + { + "name": "chargebee_list_invoices", + "description": "List invoices.", + "parameters": { + "type": "object", + "properties": { + "customer_id": { "type": "string", "description": "Filter by customer." }, + "subscription_id": { "type": "string", "description": "Filter by subscription." }, + "status": { "type": "string", "description": "paid, posted, payment_due, not_paid, voided, pending." }, + "limit": { "type": "integer", "description": "Per page." }, + "offset": { "type": "string", "description": "Cursor." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/invoices", + "queryParams": { + "customer_id[is]": "$customer_id", + "subscription_id[is]": "$subscription_id", + "status[is]": "$status", + "limit": "$limit", + "offset": "$offset" + } + } + }, + { + "name": "chargebee_get_invoice", + "description": "Fetch an invoice with line items.", + "parameters": { + "type": "object", + "properties": { + "invoiceId": { "type": "string", "description": "Invoice ID." } + }, + "required": ["invoiceId"] + }, + "endpointMapping": { "method": "GET", "path": "/invoices/{invoiceId}" } + }, + { + "name": "chargebee_list_items", + "description": "List items (Chargebee 2.0 catalog: plans + addons + charges are all items).", + "parameters": { + "type": "object", + "properties": { + "type": { "type": "string", "description": "plan, addon, charge." }, + "status": { "type": "string", "description": "active, archived, deleted." }, + "limit": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/items", + "queryParams": { + "type[is]": "$type", + "status[is]": "$status", + "limit": "$limit" + } + } + }, + { + "name": "chargebee_list_item_prices", + "description": "List item prices (each item has multiple prices per currency/interval).", + "parameters": { + "type": "object", + "properties": { + "item_id": { "type": "string", "description": "Filter by item ID." }, + "status": { "type": "string", "description": "active, archived, deleted." }, + "limit": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/item_prices", + "queryParams": { + "item_id[is]": "$item_id", + "status[is]": "$status", + "limit": "$limit" + } + } + }, + { + "name": "chargebee_list_coupons", + "description": "List coupons (discounts). Each has id, name, discount_type (fixed_amount/percentage), discount_amount/percentage, valid_till, duration_type (one_time/forever/limited_period).", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Per page." }, + "status": { "type": "string", "description": "active, expired, archived, deleted." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/coupons", + "queryParams": { + "limit": "$limit", + "status[is]": "$status" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/chargebee.live.spec.ts b/packages/backend/src/adapters/intl/chargebee.live.spec.ts new file mode 100644 index 0000000..7f17f80 --- /dev/null +++ b/packages/backend/src/adapters/intl/chargebee.live.spec.ts @@ -0,0 +1,12 @@ +import * as adapter from './chargebee.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string; authConfig: Record } }; +describe('chargebee adapter — static spec conformance', () => { + it('site-templated base URL', () => { + expect(a.connector.baseUrl).toBe('https://{{CHARGEBEE_SITE}}.chargebee.com/api/v2'); + }); + it('BASIC_AUTH with key as username + empty password', () => { + expect(a.connector.authType).toBe('BASIC_AUTH'); + expect(a.connector.authConfig.username).toBe('{{CHARGEBEE_API_KEY}}'); + expect(a.connector.authConfig.password).toBe(''); + }); +}); diff --git a/packages/backend/src/adapters/intl/crisp.json b/packages/backend/src/adapters/intl/crisp.json new file mode 100644 index 0000000..ac927c8 --- /dev/null +++ b/packages/backend/src/adapters/intl/crisp.json @@ -0,0 +1,251 @@ +{ + "slug": "crisp", + "name": "Crisp", + "description": "Drive Crisp (live chat + helpdesk) from any AI agent: conversations, messages, people, tickets. 11 tools, plugin-token Basic auth + tier header.", + "instructions": "This connector uses the Crisp REST API v1 (docs.crisp.chat/api/v1/).\n\n**Setup**:\n1. Sign in to Crisp → **Marketplace → Create a Plugin** (the modern way for API access; the legacy User token approach is deprecated).\n2. Pick scopes: at minimum `website:conversation:read`, `website:conversation:write`, `website:people:read`.\n3. After creating + reviewing, you get `plugin_id`, `plugin_identifier`, `plugin_key` (the credentials).\n4. Install the plugin on your website (give it a website_id to act on).\n5. Set:\n - `CRISP_PLUGIN_IDENTIFIER` = the identifier\n - `CRISP_PLUGIN_KEY` = the key\n - `CRISP_WEBSITE_ID` = the website ID to operate on\n\n**Authentication**: HTTP Basic with username=PLUGIN_IDENTIFIER + password=PLUGIN_KEY + custom header `X-Crisp-Tier: plugin` on every request.\n\n**Conversation model**: every conversation has a `session_id` (unique per chat). Messages live inside a conversation, ordered chronologically.\n\n**Session vs People**: Crisp tracks `people` (long-lived contact profiles) and `sessions` (single chat). A session attaches to a people record by email/phone/segment matching.\n\n**Message origins**: chat, email, sms, twitter, facebook, instagram, whatsapp, telegram, slack, messenger, line, etc. Crisp normalizes them in the same conversation thread.\n\n**Rate limits**: 100 req per 10 sec per website. On 429 back off.\n\n**Out of scope here**: plugin management, RTM/WebSocket events, MagicType (AI-generated reply suggestions), Crisp Status (status page), Triggers.", + "region": "intl", + "category": "support", + "icon": "crisp", + "docsUrl": "https://docs.crisp.chat/api/v1/", + "requiredEnvVars": ["CRISP_PLUGIN_IDENTIFIER", "CRISP_PLUGIN_KEY", "CRISP_WEBSITE_ID"], + "connector": { + "name": "Crisp v1", + "type": "REST", + "baseUrl": "https://api.crisp.chat/v1/website/{{CRISP_WEBSITE_ID}}", + "authType": "BASIC_AUTH", + "authConfig": { + "username": "{{CRISP_PLUGIN_IDENTIFIER}}", + "password": "{{CRISP_PLUGIN_KEY}}" + } + }, + "tools": [ + { + "name": "crisp_get_website", + "description": "Get website info (name, domain, logo).", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { + "method": "GET", + "path": "/", + "headers": { "X-Crisp-Tier": "plugin" } + } + }, + { + "name": "crisp_list_conversations", + "description": "List conversations with filters. Cursor-paginated (page).", + "parameters": { + "type": "object", + "properties": { + "page": { "type": "integer", "description": "Page (1-based)." }, + "search_query": { "type": "string", "description": "Free-text search." }, + "search_type": { "type": "string", "description": "Match type: 'text' (default), 'segment', 'meta-id'." }, + "search_operator": { "type": "string", "description": "and or or." }, + "filter_unread": { "type": "integer", "description": "1 = only unread." }, + "filter_resolved": { "type": "integer", "description": "1 = only resolved." }, + "filter_not_resolved": { "type": "integer", "description": "1 = only not resolved." }, + "filter_mentions": { "type": "integer", "description": "1 = only with mentions." }, + "filter_assigned": { "type": "integer", "description": "1 = only assigned." }, + "filter_unassigned": { "type": "integer", "description": "1 = only unassigned." }, + "filter_date_start": { "type": "string", "description": "ISO 8601 — filter by created_at >=." }, + "filter_date_end": { "type": "string", "description": "ISO 8601." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/conversations/{page}", + "queryParams": { + "search_query": "$search_query", + "search_type": "$search_type", + "search_operator": "$search_operator", + "filter_unread": "$filter_unread", + "filter_resolved": "$filter_resolved", + "filter_not_resolved": "$filter_not_resolved", + "filter_mentions": "$filter_mentions", + "filter_assigned": "$filter_assigned", + "filter_unassigned": "$filter_unassigned", + "filter_date_start": "$filter_date_start", + "filter_date_end": "$filter_date_end" + }, + "headers": { "X-Crisp-Tier": "plugin" } + } + }, + { + "name": "crisp_get_conversation", + "description": "Fetch a conversation by session_id with metadata + state.", + "parameters": { + "type": "object", + "properties": { + "session_id": { "type": "string", "description": "Conversation session ID." } + }, + "required": ["session_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/conversation/{session_id}", + "headers": { "X-Crisp-Tier": "plugin" } + } + }, + { + "name": "crisp_list_messages", + "description": "List messages in a conversation.", + "parameters": { + "type": "object", + "properties": { + "session_id": { "type": "string", "description": "Session ID." }, + "timestamp_before": { "type": "integer", "description": "Unix ms — messages before this." } + }, + "required": ["session_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/conversation/{session_id}/messages", + "queryParams": { "timestamp_before": "$timestamp_before" }, + "headers": { "X-Crisp-Tier": "plugin" } + } + }, + { + "name": "crisp_send_message", + "description": "Send a message in a conversation as the operator. type='text' for plain text; 'note' for internal-only notes.", + "parameters": { + "type": "object", + "properties": { + "session_id": { "type": "string", "description": "Session ID." }, + "type": { "type": "string", "description": "text, file, animation, audio, picker, field, carousel, note, event." }, + "from": { "type": "string", "description": "user or operator. operator = the agent." }, + "origin": { "type": "string", "description": "chat (default), email, etc." }, + "content": { "description": "For type=text: the string body. For other types: typed content object." }, + "user": { "type": "object", "description": "{nickname?, avatar?, type?:'website'} — display info." }, + "mentions": { "type": "array", "description": "Operator user_ids to mention." }, + "stealth": { "type": "boolean", "description": "If true, hidden from the user (internal only)." } + }, + "required": ["session_id", "type", "from", "origin", "content"] + }, + "endpointMapping": { + "method": "POST", + "path": "/conversation/{session_id}/message", + "bodyMapping": { + "type": "$type", + "from": "$from", + "origin": "$origin", + "content": "$content", + "user": "$user", + "mentions": "$mentions", + "stealth": "$stealth" + }, + "headers": { "X-Crisp-Tier": "plugin" } + } + }, + { + "name": "crisp_update_conversation_state", + "description": "Change conversation state: pending, unresolved, resolved.", + "parameters": { + "type": "object", + "properties": { + "session_id": { "type": "string", "description": "Session ID." }, + "state": { "type": "string", "description": "pending, unresolved, resolved." } + }, + "required": ["session_id", "state"] + }, + "endpointMapping": { + "method": "PATCH", + "path": "/conversation/{session_id}/state", + "bodyMapping": { "state": "$state" }, + "headers": { "X-Crisp-Tier": "plugin" } + } + }, + { + "name": "crisp_assign_conversation_routing", + "description": "Assign a conversation to an operator. operator_id is the user_id of the agent (use crisp_list_operators to discover).", + "parameters": { + "type": "object", + "properties": { + "session_id": { "type": "string", "description": "Session ID." }, + "assigned": { "type": "object", "description": "{user_id: 'operator-uuid'} or {team_id: 'team-uuid'}." } + }, + "required": ["session_id", "assigned"] + }, + "endpointMapping": { + "method": "PATCH", + "path": "/conversation/{session_id}/routing", + "bodyMapping": { "assigned": "$assigned" }, + "headers": { "X-Crisp-Tier": "plugin" } + } + }, + { + "name": "crisp_list_people_profiles", + "description": "List people (contact profiles) across the website.", + "parameters": { + "type": "object", + "properties": { + "page": { "type": "integer", "description": "Page." }, + "search_text": { "type": "string", "description": "Free-text search." }, + "filter_segments": { "type": "string", "description": "Comma-separated segments." }, + "sort_field": { "type": "string", "description": "created_at, updated_at, last_active." }, + "sort_order": { "type": "integer", "description": "1 (asc) or -1 (desc)." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/people/profiles/{page}", + "queryParams": { + "search_text": "$search_text", + "filter_segments": "$filter_segments", + "sort_field": "$sort_field", + "sort_order": "$sort_order" + }, + "headers": { "X-Crisp-Tier": "plugin" } + } + }, + { + "name": "crisp_get_people_profile", + "description": "Fetch a people profile by people_id (UUID) or email/phone (URL-encoded).", + "parameters": { + "type": "object", + "properties": { + "people_id": { "type": "string", "description": "People ID (UUID) or URL-encoded email/phone." } + }, + "required": ["people_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/people/profile/{people_id}", + "headers": { "X-Crisp-Tier": "plugin" } + } + }, + { + "name": "crisp_update_people_profile", + "description": "Update a people profile (segments, attributes, notes).", + "parameters": { + "type": "object", + "properties": { + "people_id": { "type": "string", "description": "People ID." }, + "email": { "type": "string", "description": "Email." }, + "person": { "type": "object", "description": "{nickname?, avatar?, gender?, locales?:[], phone?, address?, description?, employment?:{name,title,role?}}." }, + "segments": { "type": "array", "description": "Replace segments." }, + "notepad": { "type": "string", "description": "Internal notes." } + }, + "required": ["people_id"] + }, + "endpointMapping": { + "method": "PATCH", + "path": "/people/profile/{people_id}", + "bodyMapping": { + "email": "$email", + "person": "$person", + "segments": "$segments", + "notepad": "$notepad" + }, + "headers": { "X-Crisp-Tier": "plugin" } + } + }, + { + "name": "crisp_list_operators", + "description": "List operators (agents) on the website. Returns user_id (use as operator_id), email, full_name, avatar, role.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { + "method": "GET", + "path": "/operators/list", + "headers": { "X-Crisp-Tier": "plugin" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/crisp.live.spec.ts b/packages/backend/src/adapters/intl/crisp.live.spec.ts new file mode 100644 index 0000000..9a40ff6 --- /dev/null +++ b/packages/backend/src/adapters/intl/crisp.live.spec.ts @@ -0,0 +1,20 @@ +import * as adapter from './crisp.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: Record }; + tools: Array<{ endpointMapping: { headers?: Record } }>; +}; +describe('crisp adapter — static spec conformance', () => { + it('website-id baked into baseUrl', () => { + expect(a.connector.baseUrl).toBe('https://api.crisp.chat/v1/website/{{CRISP_WEBSITE_ID}}'); + }); + it('Basic auth with plugin identifier+key', () => { + expect(a.connector.authType).toBe('BASIC_AUTH'); + expect(a.connector.authConfig.username).toBe('{{CRISP_PLUGIN_IDENTIFIER}}'); + expect(a.connector.authConfig.password).toBe('{{CRISP_PLUGIN_KEY}}'); + }); + it('every tool sends X-Crisp-Tier: plugin', () => { + for (const t of a.tools) { + expect(t.endpointMapping.headers?.['X-Crisp-Tier']).toBe('plugin'); + } + }); +}); diff --git a/packages/backend/src/adapters/intl/recurly.json b/packages/backend/src/adapters/intl/recurly.json new file mode 100644 index 0000000..d12252e --- /dev/null +++ b/packages/backend/src/adapters/intl/recurly.json @@ -0,0 +1,291 @@ +{ + "slug": "recurly", + "name": "Recurly", + "description": "Drive Recurly (subscription billing, US-focused) from any AI agent: accounts, subscriptions, invoices, plans, coupons. 11 tools, Basic-auth.", + "instructions": "This connector uses the Recurly API v2021-02-25 (recurly.com/developers).\n\n**Setup**:\n1. Sign in to Recurly → **Integrations → API Credentials → Add Key**.\n2. Pick a Private API key (NOT a public site key — different purpose).\n3. Set `RECURLY_API_KEY`.\n\n**Authentication**: HTTP Basic with username=API_KEY, password=empty (Stripe-style).\n\n**Required Accept header pinned per-tool**: Recurly REQUIRES `Accept: application/vnd.recurly.v2021-02-25` on every request. The adapter pins this on each endpoint's headers.\n\n**Account model**: every customer is an `account`. Subscriptions, invoices, transactions all link to an account. Accounts can be `parent_account` for hierarchies (B2B).\n\n**Subscription states**: future, active, paused, expired, canceled, failed.\n\n**Currency**: each account has a 3-letter currency code. Subscriptions must use one of the plan's supported currencies.\n\n**Pagination**: cursor-based — `limit` (max 200) + response includes `has_more` + `next` URL.\n\n**Rate limits**: 1000 req/min per site. On 429 honor headers.\n\n**Out of scope here**: hosted pages, billing-info CRUD beyond list, refunds beyond invoice creation, gift cards, measured units, transactions detail, custom fields management.", + "region": "intl", + "category": "payments", + "icon": "recurly", + "docsUrl": "https://recurly.com/developers/api/", + "requiredEnvVars": ["RECURLY_API_KEY"], + "connector": { + "name": "Recurly v2021-02-25", + "type": "REST", + "baseUrl": "https://v3.recurly.com", + "authType": "BASIC_AUTH", + "authConfig": { + "username": "{{RECURLY_API_KEY}}", + "password": "" + } + }, + "tools": [ + { + "name": "recurly_list_accounts", + "description": "List accounts (customers).", + "parameters": { + "type": "object", + "properties": { + "subscriber": { "type": "boolean", "description": "true = only with active subscription." }, + "email": { "type": "string", "description": "Filter by email." }, + "begin_time": { "type": "string", "description": "ISO 8601 — accounts modified after." }, + "end_time": { "type": "string", "description": "ISO 8601." }, + "sort": { "type": "string", "description": "created_at, updated_at." }, + "order": { "type": "string", "description": "asc or desc." }, + "limit": { "type": "integer", "description": "Per page (max 200)." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/accounts", + "queryParams": { + "subscriber": "$subscriber", + "email": "$email", + "begin_time": "$begin_time", + "end_time": "$end_time", + "sort": "$sort", + "order": "$order", + "limit": "$limit" + }, + "headers": { "Accept": "application/vnd.recurly.v2021-02-25" } + } + }, + { + "name": "recurly_get_account", + "description": "Fetch one account.", + "parameters": { + "type": "object", + "properties": { + "accountId": { "type": "string", "description": "Account ID (or code prefixed with 'code-')." } + }, + "required": ["accountId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/accounts/{accountId}", + "headers": { "Accept": "application/vnd.recurly.v2021-02-25" } + } + }, + { + "name": "recurly_create_account", + "description": "Create an account. Required: code (your unique customer ID).", + "parameters": { + "type": "object", + "properties": { + "code": { "type": "string", "description": "Your unique customer code." }, + "username": { "type": "string", "description": "Username." }, + "email": { "type": "string", "description": "Email." }, + "first_name": { "type": "string", "description": "First name." }, + "last_name": { "type": "string", "description": "Last name." }, + "company": { "type": "string", "description": "Company." }, + "vat_number": { "type": "string", "description": "VAT number." }, + "preferred_locale": { "type": "string", "description": "Locale (en-US, de-DE, etc.)." }, + "preferred_time_zone": { "type": "string", "description": "TZ database name." }, + "cc_emails": { "type": "string", "description": "Comma-separated CC emails for invoices." }, + "address": { "type": "object", "description": "{street1, street2?, city, region, postal_code, country, phone?}." }, + "billing_info": { "type": "object", "description": "{token_id?, number?, month?, year?, cvv?, first_name?, last_name?, address?}." } + }, + "required": ["code"] + }, + "endpointMapping": { + "method": "POST", + "path": "/accounts", + "headers": { "Accept": "application/vnd.recurly.v2021-02-25", "Content-Type": "application/json" }, + "bodyMapping": { + "code": "$code", + "username": "$username", + "email": "$email", + "first_name": "$first_name", + "last_name": "$last_name", + "company": "$company", + "vat_number": "$vat_number", + "preferred_locale": "$preferred_locale", + "preferred_time_zone": "$preferred_time_zone", + "cc_emails": "$cc_emails", + "address": "$address", + "billing_info": "$billing_info" + } + } + }, + { + "name": "recurly_list_subscriptions", + "description": "List subscriptions with filters.", + "parameters": { + "type": "object", + "properties": { + "state": { "type": "string", "description": "future, active, paused, expired, canceled, failed." }, + "begin_time": { "type": "string", "description": "ISO 8601." }, + "end_time": { "type": "string", "description": "ISO 8601." }, + "sort": { "type": "string", "description": "created_at, updated_at." }, + "order": { "type": "string", "description": "asc or desc." }, + "limit": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/subscriptions", + "queryParams": { + "state": "$state", + "begin_time": "$begin_time", + "end_time": "$end_time", + "sort": "$sort", + "order": "$order", + "limit": "$limit" + }, + "headers": { "Accept": "application/vnd.recurly.v2021-02-25" } + } + }, + { + "name": "recurly_get_subscription", + "description": "Fetch one subscription.", + "parameters": { + "type": "object", + "properties": { + "subscriptionId": { "type": "string", "description": "Subscription UUID." } + }, + "required": ["subscriptionId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/subscriptions/{subscriptionId}", + "headers": { "Accept": "application/vnd.recurly.v2021-02-25" } + } + }, + { + "name": "recurly_create_subscription", + "description": "Create a subscription on an account. Required: account.code OR account_id + plan_code + currency.", + "parameters": { + "type": "object", + "properties": { + "plan_code": { "type": "string", "description": "Plan code." }, + "currency": { "type": "string", "description": "ISO 4217 currency (USD, EUR, etc.)." }, + "account": { "type": "object", "description": "{code:'EXISTING' OR code+billing_info+address+...} — Recurly auto-creates the account if it doesn't exist." }, + "unit_amount": { "type": "number", "description": "Override plan price." }, + "quantity": { "type": "integer", "description": "Default 1." }, + "coupon_code": { "type": "string", "description": "Coupon to apply." }, + "trial_ends_at": { "type": "string", "description": "ISO 8601 — extend/override trial." }, + "starts_at": { "type": "string", "description": "ISO 8601 — future start." }, + "po_number": { "type": "string", "description": "Purchase order." }, + "auto_renew": { "type": "boolean", "description": "Default true. false = cancels at period end." } + }, + "required": ["plan_code", "currency", "account"] + }, + "endpointMapping": { + "method": "POST", + "path": "/subscriptions", + "headers": { "Accept": "application/vnd.recurly.v2021-02-25", "Content-Type": "application/json" }, + "bodyMapping": { + "plan_code": "$plan_code", + "currency": "$currency", + "account": "$account", + "unit_amount": "$unit_amount", + "quantity": "$quantity", + "coupon_code": "$coupon_code", + "trial_ends_at": "$trial_ends_at", + "starts_at": "$starts_at", + "po_number": "$po_number", + "auto_renew": "$auto_renew" + } + } + }, + { + "name": "recurly_cancel_subscription", + "description": "Cancel a subscription at the end of the current term. Use recurly_terminate for immediate.", + "parameters": { + "type": "object", + "properties": { + "subscriptionId": { "type": "string", "description": "Subscription UUID." } + }, + "required": ["subscriptionId"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/subscriptions/{subscriptionId}/cancel", + "headers": { "Accept": "application/vnd.recurly.v2021-02-25" } + } + }, + { + "name": "recurly_terminate_subscription", + "description": "Immediately terminate a subscription with refund options (none/partial/full).", + "parameters": { + "type": "object", + "properties": { + "subscriptionId": { "type": "string", "description": "Subscription UUID." }, + "refund": { "type": "string", "description": "none (default), partial, full." }, + "charge": { "type": "boolean", "description": "If true, post any unsettled charges." } + }, + "required": ["subscriptionId"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/subscriptions/{subscriptionId}/terminate", + "queryParams": { + "refund": "$refund", + "charge": "$charge" + }, + "headers": { "Accept": "application/vnd.recurly.v2021-02-25" } + } + }, + { + "name": "recurly_list_invoices", + "description": "List invoices across the site.", + "parameters": { + "type": "object", + "properties": { + "state": { "type": "string", "description": "pending, processing, past_due, paid, failed, voided, closed." }, + "type": { "type": "string", "description": "charge, credit, legacy, non_legacy." }, + "begin_time": { "type": "string", "description": "ISO 8601." }, + "end_time": { "type": "string", "description": "ISO 8601." }, + "sort": { "type": "string", "description": "created_at, updated_at." }, + "limit": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/invoices", + "queryParams": { + "state": "$state", + "type": "$type", + "begin_time": "$begin_time", + "end_time": "$end_time", + "sort": "$sort", + "limit": "$limit" + }, + "headers": { "Accept": "application/vnd.recurly.v2021-02-25" } + } + }, + { + "name": "recurly_list_plans", + "description": "List plans (subscription products). Each has code, name, intervals[], currencies[], trial_length, setup_fee.", + "parameters": { + "type": "object", + "properties": { + "state": { "type": "string", "description": "active, inactive." }, + "limit": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/plans", + "queryParams": { "state": "$state", "limit": "$limit" }, + "headers": { "Accept": "application/vnd.recurly.v2021-02-25" } + } + }, + { + "name": "recurly_list_coupons", + "description": "List coupons.", + "parameters": { + "type": "object", + "properties": { + "state": { "type": "string", "description": "redeemable, expired, maxed_out, inactive." }, + "limit": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/coupons", + "queryParams": { "state": "$state", "limit": "$limit" }, + "headers": { "Accept": "application/vnd.recurly.v2021-02-25" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/recurly.live.spec.ts b/packages/backend/src/adapters/intl/recurly.live.spec.ts new file mode 100644 index 0000000..9b57db5 --- /dev/null +++ b/packages/backend/src/adapters/intl/recurly.live.spec.ts @@ -0,0 +1,17 @@ +import * as adapter from './recurly.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: Record }; + tools: Array<{ endpointMapping: { headers?: Record } }>; +}; +describe('recurly adapter — static spec conformance', () => { + it('v3.recurly.com', () => expect(a.connector.baseUrl).toBe('https://v3.recurly.com')); + it('Basic auth, key as user', () => { + expect(a.connector.authType).toBe('BASIC_AUTH'); + expect(a.connector.authConfig.username).toBe('{{RECURLY_API_KEY}}'); + }); + it('every tool pins the required version Accept header', () => { + for (const t of a.tools) { + expect(t.endpointMapping.headers?.Accept).toBe('application/vnd.recurly.v2021-02-25'); + } + }); +}); diff --git a/packages/backend/src/adapters/intl/statsig.json b/packages/backend/src/adapters/intl/statsig.json new file mode 100644 index 0000000..31f6c60 --- /dev/null +++ b/packages/backend/src/adapters/intl/statsig.json @@ -0,0 +1,215 @@ +{ + "slug": "statsig", + "name": "Statsig", + "description": "Drive Statsig (feature flags + experiments + analytics) from any AI agent: evaluate gates/dynamic configs/experiments, log events, manage console-side gates and experiments. 10 tools, server-secret-key auth.", + "instructions": "This connector uses the Statsig HTTP API (docs.statsig.com).\n\n**Two distinct APIs**:\n - **Server SDK API** (`api.statsig.com/v1/...`) — runtime evaluation + event logging. Requires `STATSIG_SERVER_SECRET_KEY` (starts with `secret-`).\n - **Console API** (`statsigapi.net/console/v1/...`) — admin operations like creating gates. Requires `STATSIG_CONSOLE_API_KEY` (starts with `console-`).\nThe adapter uses the Server SDK base by default; tools that hit the Console API set explicit absolute URLs in their endpointMapping.\n\n**Setup**:\n1. Sign in to https://console.statsig.com → **Settings → Keys & Environments**.\n2. Copy the **Server Secret Key** (`secret-...`) for runtime ops.\n3. Copy a **Console API Key** (`console-...`) for admin ops (Settings → Project Settings → API Keys → Create Console API Key).\n4. Set `STATSIG_SERVER_SECRET_KEY` and (optionally, for admin tools) `STATSIG_CONSOLE_API_KEY`.\n\n**Authentication**: Server SDK uses `statsig-api-key: ${SERVER_SECRET}` header. Console uses `STATSIG-API-KEY: ${CONSOLE_KEY}` header.\n\n**Statsig-Client-Time header**: server SDK endpoints prefer a `STATSIG-CLIENT-TIME` header (Unix ms) — Statsig uses for evaluation timing. Skip for simple cases.\n\n**User object**: every gate/config/experiment evaluation needs a `user` object with at minimum `userID` (your stable user ID). Add `email`, `country`, `appVersion`, `custom` for targeting accuracy.\n\n**Out of scope here**: Layers API (experiment layers), holdouts, GitHub integration, audit log, project membership management.", + "region": "intl", + "category": "analytics", + "icon": "statsig", + "docsUrl": "https://docs.statsig.com/server-core/http-api/", + "requiredEnvVars": ["STATSIG_SERVER_SECRET_KEY"], + "connector": { + "name": "Statsig Server SDK", + "type": "REST", + "baseUrl": "https://api.statsig.com/v1", + "authType": "API_KEY", + "authConfig": { + "headerName": "statsig-api-key", + "apiKey": "{{STATSIG_SERVER_SECRET_KEY}}" + } + }, + "tools": [ + { + "name": "statsig_check_gate", + "description": "Evaluate a feature gate (boolean flag) for a user. Returns {name, value:bool, rule_id, group_name, secondary_exposures}.", + "parameters": { + "type": "object", + "properties": { + "user": { "type": "object", "description": "{userID, email?, country?, ip?, userAgent?, appVersion?, custom?:{}, customIDs?:{}, privateAttributes?:{}}." }, + "gateName": { "type": "string", "description": "Gate name (case-sensitive)." } + }, + "required": ["user", "gateName"] + }, + "endpointMapping": { + "method": "POST", + "path": "/check_gate", + "bodyMapping": { + "user": "$user", + "gateName": "$gateName" + } + } + }, + { + "name": "statsig_get_config", + "description": "Evaluate a Dynamic Config for a user. Returns {name, value:{...}, rule_id, group_name}.", + "parameters": { + "type": "object", + "properties": { + "user": { "type": "object", "description": "User object." }, + "configName": { "type": "string", "description": "Dynamic Config name." } + }, + "required": ["user", "configName"] + }, + "endpointMapping": { + "method": "POST", + "path": "/get_config", + "bodyMapping": { + "user": "$user", + "configName": "$configName" + } + } + }, + { + "name": "statsig_get_experiment", + "description": "Evaluate an experiment for a user. Returns variant assignment + dynamic-config-like value object.", + "parameters": { + "type": "object", + "properties": { + "user": { "type": "object", "description": "User object." }, + "experimentName": { "type": "string", "description": "Experiment name." } + }, + "required": ["user", "experimentName"] + }, + "endpointMapping": { + "method": "POST", + "path": "/get_experiment", + "bodyMapping": { + "user": "$user", + "experimentName": "$experimentName" + } + } + }, + { + "name": "statsig_initialize", + "description": "Bulk evaluate ALL gates/configs/experiments for a user in one call. Returns the complete state — useful for client bootstrap.", + "parameters": { + "type": "object", + "properties": { + "user": { "type": "object", "description": "User object." }, + "statsigMetadata": { "type": "object", "description": "{sdkType?, sdkVersion?, sessionID?} — optional SDK metadata." } + }, + "required": ["user"] + }, + "endpointMapping": { + "method": "POST", + "path": "/initialize", + "bodyMapping": { + "user": "$user", + "statsigMetadata": "$statsigMetadata" + } + } + }, + { + "name": "statsig_log_event", + "description": "Log a custom event for analytics + experiment exposure tracking. Events are async — Statsig buffers them.", + "parameters": { + "type": "object", + "properties": { + "events": { + "type": "array", + "description": "[{user, eventName, value?, metadata?, time? (Unix ms)}]. Batch many at once." + } + }, + "required": ["events"] + }, + "endpointMapping": { + "method": "POST", + "path": "/log_event", + "bodyMapping": { + "events": "$events" + } + } + }, + { + "name": "statsig_list_gates", + "description": "Console: list feature gates defined in the project. Requires CONSOLE API key (set STATSIG_CONSOLE_API_KEY).", + "parameters": { + "type": "object", + "properties": { + "page": { "type": "integer", "description": "Page." }, + "limit": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "https://statsigapi.net/console/v1/gates", + "queryParams": { "page": "$page", "limit": "$limit" }, + "headers": { "STATSIG-API-KEY": "{{STATSIG_CONSOLE_API_KEY}}" } + } + }, + { + "name": "statsig_get_gate_config", + "description": "Console: fetch a gate's definition (rules, targeting). Requires CONSOLE key.", + "parameters": { + "type": "object", + "properties": { + "gateName": { "type": "string", "description": "Gate name." } + }, + "required": ["gateName"] + }, + "endpointMapping": { + "method": "GET", + "path": "https://statsigapi.net/console/v1/gates/{gateName}", + "headers": { "STATSIG-API-KEY": "{{STATSIG_CONSOLE_API_KEY}}" } + } + }, + { + "name": "statsig_create_gate", + "description": "Console: create a new feature gate. Requires CONSOLE key.", + "parameters": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Gate name (unique)." }, + "description": { "type": "string", "description": "Description." }, + "idType": { "type": "string", "description": "userID, stableID, or custom name." }, + "rules": { "type": "array", "description": "Targeting rules: [{name, conditions:[{type,operator,targetValue,field?}], passPercentage:0-100, returnValue?:bool}]." } + }, + "required": ["name"] + }, + "endpointMapping": { + "method": "POST", + "path": "https://statsigapi.net/console/v1/gates", + "bodyMapping": { + "name": "$name", + "description": "$description", + "idType": "$idType", + "rules": "$rules" + }, + "headers": { "STATSIG-API-KEY": "{{STATSIG_CONSOLE_API_KEY}}" } + } + }, + { + "name": "statsig_list_experiments", + "description": "Console: list experiments. Requires CONSOLE key.", + "parameters": { + "type": "object", + "properties": { + "page": { "type": "integer", "description": "Page." }, + "limit": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "https://statsigapi.net/console/v1/experiments", + "queryParams": { "page": "$page", "limit": "$limit" }, + "headers": { "STATSIG-API-KEY": "{{STATSIG_CONSOLE_API_KEY}}" } + } + }, + { + "name": "statsig_get_experiment_results", + "description": "Console: fetch experiment metric results (pulse readout). Requires CONSOLE key.", + "parameters": { + "type": "object", + "properties": { + "experimentName": { "type": "string", "description": "Experiment name." } + }, + "required": ["experimentName"] + }, + "endpointMapping": { + "method": "GET", + "path": "https://statsigapi.net/console/v1/experiments/{experimentName}/results", + "headers": { "STATSIG-API-KEY": "{{STATSIG_CONSOLE_API_KEY}}" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/statsig.live.spec.ts b/packages/backend/src/adapters/intl/statsig.live.spec.ts new file mode 100644 index 0000000..108ec97 --- /dev/null +++ b/packages/backend/src/adapters/intl/statsig.live.spec.ts @@ -0,0 +1,15 @@ +import * as adapter from './statsig.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string }; + tools: Array<{ name: string; endpointMapping: { path: string } }>; +}; +describe('statsig adapter — static spec conformance', () => { + it('SDK base URL is api.statsig.com/v1', () => expect(a.connector.baseUrl).toBe('https://api.statsig.com/v1')); + it('Console tools use absolute URLs to statsigapi.net (different host)', () => { + const consoleTools = a.tools.filter((t) => t.endpointMapping.path.startsWith('https://statsigapi.net')); + expect(consoleTools.length).toBeGreaterThan(0); + for (const t of consoleTools) { + expect(t.endpointMapping.path).toMatch(/^https:\/\/statsigapi\.net\/console\/v1\//); + } + }); +}); diff --git a/packages/backend/src/connectors/engines/rest.engine.ts b/packages/backend/src/connectors/engines/rest.engine.ts index a3d49ed..46775f4 100644 --- a/packages/backend/src/connectors/engines/rest.engine.ts +++ b/packages/backend/src/connectors/engines/rest.engine.ts @@ -48,7 +48,11 @@ export class RestEngine { path = path.replace(`{${key}}`, String(value)); } - const url = `${config.baseUrl}${path}`; + // Allow per-tool absolute URLs to escape the connector's baseUrl. Useful + // when a vendor publishes multiple distinct API hosts under one product + // (e.g. Statsig: api.statsig.com for SDK + statsigapi.net for Console), + // so a single adapter can cover both without two connector records. + const url = /^https?:\/\//i.test(path) ? path : `${config.baseUrl}${path}`; await assertSafeOutboundUrl(url); // Resolve dynamic headers from endpoint mapping ($param references) From ae33d04eb7add1d2f0ab6c92b87b74c6f0a50556 Mon Sep 17 00:00:00 2001 From: Matteo Date: Wed, 20 May 2026 13:10:18 +0200 Subject: [PATCH 13/19] connectors: add Clearbit, Snov.io, GitBook, beehiiv, Wufoo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 10 — enrichment + knowledge + publishing + forms. - Clearbit (HubSpot Breeze Intelligence): 6 tools — person enrich by email, company by domain, combined, discovery search, reveal by IP, autocomplete. Multiple distinct subdomains (person/company/discovery/reveal/autocomplete) handled via per-tool absolute URLs. - Snov.io v1: 9 tools — domain search, email finder by name/URL, email verifier, prospect lists CRUD, drip campaigns list. OAuth2 client-credentials Bearer. - GitBook v1: 9 tools — orgs/spaces/collections, page tree, page content (JSON AST or Markdown), full-text search, markdown import as a page write path. Bearer. - beehiiv v2: 11 tools — publication info, posts CRUD with audience/platform/status filters, subscriptions CRUD with custom fields, segments, test-email send. Publication ID baked into baseUrl. - Wufoo v3: 6 tools — forms, fields discovery, entries with the vendor's quirky filter syntax, programmatic entry submission. Subdomain-templated URL. Basic auth with literal 'footastic' password (Wufoo convention). Catalog: 95 adapters (55/81 of the greenfield batch done, ~68%). --- packages/backend/src/adapters/catalog.ts | 10 + .../backend/src/adapters/intl/beehiiv.json | 242 ++++++++++++++++++ .../src/adapters/intl/beehiiv.live.spec.ts | 8 + .../backend/src/adapters/intl/clearbit.json | 135 ++++++++++ .../src/adapters/intl/clearbit.live.spec.ts | 16 ++ .../backend/src/adapters/intl/gitbook.json | 162 ++++++++++++ .../src/adapters/intl/gitbook.live.spec.ts | 6 + packages/backend/src/adapters/intl/snov.json | 174 +++++++++++++ .../src/adapters/intl/snov.live.spec.ts | 6 + packages/backend/src/adapters/intl/wufoo.json | 135 ++++++++++ .../src/adapters/intl/wufoo.live.spec.ts | 12 + 11 files changed, 906 insertions(+) create mode 100644 packages/backend/src/adapters/intl/beehiiv.json create mode 100644 packages/backend/src/adapters/intl/beehiiv.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/clearbit.json create mode 100644 packages/backend/src/adapters/intl/clearbit.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/gitbook.json create mode 100644 packages/backend/src/adapters/intl/gitbook.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/snov.json create mode 100644 packages/backend/src/adapters/intl/snov.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/wufoo.json create mode 100644 packages/backend/src/adapters/intl/wufoo.live.spec.ts diff --git a/packages/backend/src/adapters/catalog.ts b/packages/backend/src/adapters/catalog.ts index 9986100..541f569 100644 --- a/packages/backend/src/adapters/catalog.ts +++ b/packages/backend/src/adapters/catalog.ts @@ -36,10 +36,12 @@ import * as acuityScheduling from './intl/acuity-scheduling.json'; import * as adyen from './intl/adyen.json'; import * as apollo from './intl/apollo.json'; import * as basecamp from './intl/basecamp.json'; +import * as beehiiv from './intl/beehiiv.json'; import * as bigcommerce from './intl/bigcommerce.json'; import * as brevo from './intl/brevo.json'; import * as calendly from './intl/calendly.json'; import * as chargebee from './intl/chargebee.json'; +import * as clearbit from './intl/clearbit.json'; import * as clickup from './intl/clickup.json'; import * as close from './intl/close.json'; import * as coda from './intl/coda.json'; @@ -52,6 +54,7 @@ import * as fathom from './intl/fathom.json'; import * as freshdesk from './intl/freshdesk.json'; import * as front from './intl/front.json'; import * as ghost from './intl/ghost.json'; +import * as gitbook from './intl/gitbook.json'; import * as heap from './intl/heap.json'; import * as helpScout from './intl/help-scout.json'; import * as hunter from './intl/hunter.json'; @@ -72,6 +75,7 @@ import * as recurly from './intl/recurly.json'; import * as reddit from './intl/reddit.json'; import * as salesloft from './intl/salesloft.json'; import * as sendgrid from './intl/sendgrid.json'; +import * as snov from './intl/snov.json'; import * as sorare from './intl/sorare.json'; import * as statsig from './intl/statsig.json'; import * as substack from './intl/substack.json'; @@ -84,6 +88,7 @@ import * as typeform from './intl/typeform.json'; import * as whatsappBusiness from './intl/whatsapp-business.json'; import * as woocommerce from './intl/woocommerce.json'; import * as wordpress from './intl/wordpress.json'; +import * as wufoo from './intl/wufoo.json'; import * as zendesk from './intl/zendesk.json'; import * as mercadoLibre from './br/mercado-libre.json'; import * as razorpay from './in/razorpay.json'; @@ -198,10 +203,12 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ adyen as unknown as AdapterDefinition, apollo as unknown as AdapterDefinition, basecamp as unknown as AdapterDefinition, + beehiiv as unknown as AdapterDefinition, bigcommerce as unknown as AdapterDefinition, brevo as unknown as AdapterDefinition, calendly as unknown as AdapterDefinition, chargebee as unknown as AdapterDefinition, + clearbit as unknown as AdapterDefinition, clickup as unknown as AdapterDefinition, close as unknown as AdapterDefinition, coda as unknown as AdapterDefinition, @@ -214,6 +221,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ freshdesk as unknown as AdapterDefinition, front as unknown as AdapterDefinition, ghost as unknown as AdapterDefinition, + gitbook as unknown as AdapterDefinition, heap as unknown as AdapterDefinition, helpScout as unknown as AdapterDefinition, hunter as unknown as AdapterDefinition, @@ -234,6 +242,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ reddit as unknown as AdapterDefinition, salesloft as unknown as AdapterDefinition, sendgrid as unknown as AdapterDefinition, + snov as unknown as AdapterDefinition, sorare as unknown as AdapterDefinition, statsig as unknown as AdapterDefinition, substack as unknown as AdapterDefinition, @@ -246,6 +255,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ whatsappBusiness as unknown as AdapterDefinition, woocommerce as unknown as AdapterDefinition, wordpress as unknown as AdapterDefinition, + wufoo as unknown as AdapterDefinition, zendesk as unknown as AdapterDefinition, mercadoLibre as unknown as AdapterDefinition, razorpay as unknown as AdapterDefinition, diff --git a/packages/backend/src/adapters/intl/beehiiv.json b/packages/backend/src/adapters/intl/beehiiv.json new file mode 100644 index 0000000..24df094 --- /dev/null +++ b/packages/backend/src/adapters/intl/beehiiv.json @@ -0,0 +1,242 @@ +{ + "slug": "beehiiv", + "name": "beehiiv", + "description": "Drive beehiiv (modern newsletter platform) from any AI agent: publications, posts, subscriptions, segments, custom fields. 11 tools, Bearer auth.", + "instructions": "This connector uses the beehiiv API v2 (developers.beehiiv.com).\n\n**Setup**:\n1. Sign in to https://app.beehiiv.com → bottom-left avatar → **Settings → Integrations → API → Generate New API key**.\n2. Set the key to `BEEHIIV_API_KEY`.\n3. Note your **Publication ID** — visible in the URL when you're on a publication's dashboard (`/p/pub_XXX/...`). Set `BEEHIIV_PUBLICATION_ID`.\n\n**Authentication**: `Authorization: Bearer ${BEEHIIV_API_KEY}`.\n\n**Publication-scoped URL**: most endpoints live under `/publications/{publicationId}/...`. The adapter bakes publication ID into baseUrl.\n\n**Subscription model**: each subscriber has email, status (active, validating, inactive, pending, needs_attention), tier (free, premium), subscription_premium_tier_names[], referring_site, utm_source, utm_medium, utm_campaign, custom_fields[].\n\n**Posts**: each post (newsletter issue) has id, title, subtitle, slug, status (draft/confirmed/archived/scheduled), audience (free/premium/all), platform (web/email/both), web_url, content (HTML).\n\n**Pagination**: `?limit=N&page=M` (max 100).\n\n**Out of scope here**: payments/premium-tier management, ads, segments-builder, automation journeys.", + "region": "intl", + "category": "publishing", + "icon": "beehiiv", + "docsUrl": "https://developers.beehiiv.com/", + "requiredEnvVars": ["BEEHIIV_API_KEY", "BEEHIIV_PUBLICATION_ID"], + "connector": { + "name": "beehiiv v2", + "type": "REST", + "baseUrl": "https://api.beehiiv.com/v2/publications/{{BEEHIIV_PUBLICATION_ID}}", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{BEEHIIV_API_KEY}}" + } + }, + "tools": [ + { + "name": "beehiiv_get_publication", + "description": "Get the publication metadata (name, organization_name, referral_program_enabled, created).", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "" } + }, + { + "name": "beehiiv_list_posts", + "description": "List posts (newsletter issues). Filter by status / audience / platform.", + "parameters": { + "type": "object", + "properties": { + "expand": { "type": "string", "description": "Comma-separated: free_email_click_count, free_email_open_count, free_web_view_count, premium_email_click_count, etc." }, + "audience": { "type": "string", "description": "free, premium, all." }, + "platform": { "type": "string", "description": "web, email, both, all." }, + "status": { "type": "string", "description": "draft, confirmed, archived, scheduled." }, + "content_tags": { "type": "string", "description": "Comma-separated tag names." }, + "limit": { "type": "integer", "description": "Per page (max 100)." }, + "page": { "type": "integer", "description": "Page." }, + "order_by": { "type": "string", "description": "created, publish_date, displayed_date." }, + "direction": { "type": "string", "description": "asc or desc." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/posts", + "queryParams": { + "expand[]": "$expand", + "audience": "$audience", + "platform": "$platform", + "status": "$status", + "content_tags[]": "$content_tags", + "limit": "$limit", + "page": "$page", + "order_by": "$order_by", + "direction": "$direction" + } + } + }, + { + "name": "beehiiv_get_post", + "description": "Fetch a single post with full content.", + "parameters": { + "type": "object", + "properties": { + "postId": { "type": "string", "description": "Post ID." }, + "expand": { "type": "string", "description": "Comma-separated expansions." } + }, + "required": ["postId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/posts/{postId}", + "queryParams": { "expand[]": "$expand" } + } + }, + { + "name": "beehiiv_list_subscriptions", + "description": "List subscribers with filters.", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "Filter by exact email." }, + "status": { "type": "string", "description": "active, validating, inactive, pending, needs_attention." }, + "tier": { "type": "string", "description": "free, premium." }, + "limit": { "type": "integer", "description": "Per page." }, + "page": { "type": "integer", "description": "Page." }, + "order_by": { "type": "string", "description": "created, email." }, + "direction": { "type": "string", "description": "asc or desc." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/subscriptions", + "queryParams": { + "email": "$email", + "status": "$status", + "tier": "$tier", + "limit": "$limit", + "page": "$page", + "order_by": "$order_by", + "direction": "$direction" + } + } + }, + { + "name": "beehiiv_get_subscription", + "description": "Fetch a subscriber by ID or email.", + "parameters": { + "type": "object", + "properties": { + "identifier": { "type": "string", "description": "Subscription ID OR URL-encoded email." } + }, + "required": ["identifier"] + }, + "endpointMapping": { "method": "GET", "path": "/subscriptions/{identifier}" } + }, + { + "name": "beehiiv_create_subscription", + "description": "Create a subscription (subscribe an email). Idempotent — re-subscribing an existing email returns the existing record.", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "Subscriber email." }, + "reactivate_existing": { "type": "boolean", "description": "If true, reactivate inactive subscriber." }, + "send_welcome_email": { "type": "boolean", "description": "If true, trigger welcome email." }, + "utm_source": { "type": "string", "description": "Attribution." }, + "utm_medium": { "type": "string", "description": "Attribution." }, + "utm_campaign": { "type": "string", "description": "Attribution." }, + "referring_site": { "type": "string", "description": "Referring URL." }, + "custom_fields": { "type": "array", "description": "[{name, value}] — custom field overrides." } + }, + "required": ["email"] + }, + "endpointMapping": { + "method": "POST", + "path": "/subscriptions", + "bodyMapping": { + "email": "$email", + "reactivate_existing": "$reactivate_existing", + "send_welcome_email": "$send_welcome_email", + "utm_source": "$utm_source", + "utm_medium": "$utm_medium", + "utm_campaign": "$utm_campaign", + "referring_site": "$referring_site", + "custom_fields": "$custom_fields" + } + } + }, + { + "name": "beehiiv_update_subscription", + "description": "Update a subscriber (custom fields, UTM tags, tier).", + "parameters": { + "type": "object", + "properties": { + "subscriptionId": { "type": "string", "description": "Subscription ID." }, + "tier": { "type": "string", "description": "free, premium." }, + "stripe_customer_id": { "type": "string", "description": "Stripe customer link." }, + "unsubscribe": { "type": "boolean", "description": "If true, mark unsubscribed." }, + "custom_fields": { "type": "array", "description": "[{name, value}]." } + }, + "required": ["subscriptionId"] + }, + "endpointMapping": { + "method": "PATCH", + "path": "/subscriptions/{subscriptionId}", + "bodyMapping": { + "tier": "$tier", + "stripe_customer_id": "$stripe_customer_id", + "unsubscribe": "$unsubscribe", + "custom_fields": "$custom_fields" + } + } + }, + { + "name": "beehiiv_delete_subscription", + "description": "Permanently delete a subscriber (GDPR friendly).", + "parameters": { + "type": "object", + "properties": { + "subscriptionId": { "type": "string", "description": "Subscription ID." } + }, + "required": ["subscriptionId"] + }, + "endpointMapping": { "method": "DELETE", "path": "/subscriptions/{subscriptionId}" } + }, + { + "name": "beehiiv_list_segments", + "description": "List segments (saved subscriber queries).", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Per page." }, + "page": { "type": "integer", "description": "Page." }, + "status": { "type": "string", "description": "completed, calculating, pending." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/segments", + "queryParams": { + "limit": "$limit", + "page": "$page", + "status": "$status" + } + } + }, + { + "name": "beehiiv_list_custom_fields", + "description": "List custom fields defined on subscribers.", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Per page." }, + "page": { "type": "integer", "description": "Page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/custom_fields", + "queryParams": { "limit": "$limit", "page": "$page" } + } + }, + { + "name": "beehiiv_send_test_email", + "description": "Send a test email of a post to specific addresses. Useful for previewing before scheduling.", + "parameters": { + "type": "object", + "properties": { + "postId": { "type": "string", "description": "Post ID." }, + "emails": { "type": "array", "description": "Array of email strings to send to (max 5)." } + }, + "required": ["postId", "emails"] + }, + "endpointMapping": { + "method": "POST", + "path": "/posts/{postId}/test", + "bodyMapping": { "emails": "$emails" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/beehiiv.live.spec.ts b/packages/backend/src/adapters/intl/beehiiv.live.spec.ts new file mode 100644 index 0000000..04c5ca6 --- /dev/null +++ b/packages/backend/src/adapters/intl/beehiiv.live.spec.ts @@ -0,0 +1,8 @@ +import * as adapter from './beehiiv.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string; authConfig: Record } }; +describe('beehiiv adapter — static spec conformance', () => { + it('publication-templated base URL', () => { + expect(a.connector.baseUrl).toBe('https://api.beehiiv.com/v2/publications/{{BEEHIIV_PUBLICATION_ID}}'); + }); + it('Bearer auth', () => expect(a.connector.authType).toBe('BEARER_TOKEN')); +}); diff --git a/packages/backend/src/adapters/intl/clearbit.json b/packages/backend/src/adapters/intl/clearbit.json new file mode 100644 index 0000000..f19daf7 --- /dev/null +++ b/packages/backend/src/adapters/intl/clearbit.json @@ -0,0 +1,135 @@ +{ + "slug": "clearbit", + "name": "Clearbit", + "description": "Enrich emails, domains and IPs via Clearbit (HubSpot Breeze Intelligence). 6 tools, Bearer auth. Marketing intelligence + technographics + firmographics.", + "instructions": "This connector uses the Clearbit Enrichment + Discovery + Risk + Reveal APIs (dashboard.clearbit.com).\n\n**Note**: Clearbit was acquired by HubSpot in 2023 and is now sold as **HubSpot Breeze Intelligence**. The legacy clearbit.com API endpoints remain functional for existing customers as of 2025 but new sign-ups go through HubSpot. Set your CLEARBIT_API_KEY exactly as Clearbit issues it.\n\n**Setup**:\n1. Sign in to https://dashboard.clearbit.com → **API Keys** (or for HubSpot Breeze: HubSpot Settings → Integrations → Breeze Intelligence → API).\n2. Copy the secret API key (prefixed with `sk_`).\n3. Set `CLEARBIT_API_KEY`.\n\n**Authentication**: HTTP Basic with username=API_KEY, password=empty (Stripe-style). The adapter uses BASIC_AUTH.\n\n**Endpoints split across subdomains**:\n - `person.clearbit.com/v2/people/find?email=X` — Person enrichment by email\n - `company.clearbit.com/v2/companies/find?domain=X` — Company enrichment by domain\n - `discovery.clearbit.com/v1/companies/search?query=Q` — Search companies by criteria\n - `risk.clearbit.com/v1/calculate` — Risk score on an email or IP\n - `reveal.clearbit.com/v1/companies/find?ip=X` — Reverse-IP-to-company (B2B intent)\nThe adapter uses person.clearbit.com as baseUrl and absolute URLs for others.\n\n**Credit costs**: each enrichment call costs credits. Plans range from 1k/month free to 100k+/month enterprise. Use `/health` (free) for testing.\n\n**Webhook mode**: Clearbit can push enrichment results when the underlying data updates — out of scope for this connector.\n\n**Out of scope here**: Salesforce sync, deanonymization, x-targets, account-targeting webhook receivers.", + "region": "intl", + "category": "enrichment", + "icon": "clearbit", + "docsUrl": "https://dashboard.clearbit.com/docs", + "requiredEnvVars": ["CLEARBIT_API_KEY"], + "connector": { + "name": "Clearbit", + "type": "REST", + "baseUrl": "https://person.clearbit.com", + "authType": "BASIC_AUTH", + "authConfig": { + "username": "{{CLEARBIT_API_KEY}}", + "password": "" + } + }, + "tools": [ + { + "name": "clearbit_person_enrich", + "description": "Enrich a person by email. Returns name, location, employment (company name, title, role, seniority, domain), social profiles (twitter, linkedin, github, etc.), avatar.", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "Email to enrich." }, + "webhook_url": { "type": "string", "description": "If passed, Clearbit returns 202 and posts results to this URL when data is ready." } + }, + "required": ["email"] + }, + "endpointMapping": { + "method": "GET", + "path": "/v2/people/find", + "queryParams": { + "email": "$email", + "webhook_url": "$webhook_url" + } + } + }, + { + "name": "clearbit_company_enrich", + "description": "Enrich a company by domain. Returns name, legal_name, description, industry, employees, employeesRange, founded_year, location, tech[] (tech stack), metrics (annualRevenue, raisedFunding, etc.), social profiles, logo.", + "parameters": { + "type": "object", + "properties": { + "domain": { "type": "string", "description": "Domain (e.g. 'acme.com')." }, + "webhook_url": { "type": "string", "description": "Async webhook URL." } + }, + "required": ["domain"] + }, + "endpointMapping": { + "method": "GET", + "path": "https://company.clearbit.com/v2/companies/find", + "queryParams": { + "domain": "$domain", + "webhook_url": "$webhook_url" + } + } + }, + { + "name": "clearbit_combined_enrich", + "description": "Combined person + company enrichment in one call by email. Returns {person: {...}, company: {...}}.", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "Email." } + }, + "required": ["email"] + }, + "endpointMapping": { + "method": "GET", + "path": "https://person-stream.clearbit.com/v2/combined/find", + "queryParams": { "email": "$email" } + } + }, + { + "name": "clearbit_discovery_search", + "description": "Search Clearbit's company database by filters (industry, size, location, tech, type). Useful for ICP-based prospecting. Returns paginated companies (NOT contacts — pair with another tool for people).", + "parameters": { + "type": "object", + "properties": { + "query": { "type": "string", "description": "Filter expression, e.g. 'tech:Salesforce AND employees:1000~10000 AND location:United States'. See https://dashboard.clearbit.com/docs#discovery-api-search-companies for full syntax." }, + "sort": { "type": "string", "description": "alexa_us_rank, alexa_global_rank, employees, annual_revenue (with :asc or :desc suffix)." }, + "page": { "type": "integer", "description": "1-based page." }, + "page_size": { "type": "integer", "description": "Per page (default 50, max 100)." } + }, + "required": ["query"] + }, + "endpointMapping": { + "method": "GET", + "path": "https://discovery.clearbit.com/v1/companies/search", + "queryParams": { + "query": "$query", + "sort": "$sort", + "page": "$page", + "page_size": "$page_size" + } + } + }, + { + "name": "clearbit_reveal_by_ip", + "description": "Reverse-IP lookup: given an IP, return the company it belongs to (B2B intent / website-visitor identification). Great for showing 'Acme Corp is browsing your site'.", + "parameters": { + "type": "object", + "properties": { + "ip": { "type": "string", "description": "IPv4 or IPv6 address." } + }, + "required": ["ip"] + }, + "endpointMapping": { + "method": "GET", + "path": "https://reveal.clearbit.com/v1/companies/find", + "queryParams": { "ip": "$ip" } + } + }, + { + "name": "clearbit_company_autocomplete", + "description": "Autocomplete company names (no auth required, public). Returns top matches with name, domain, logo. Use for company-picker UIs.", + "parameters": { + "type": "object", + "properties": { + "query": { "type": "string", "description": "Partial company name." } + }, + "required": ["query"] + }, + "endpointMapping": { + "method": "GET", + "path": "https://autocomplete.clearbit.com/v1/companies/suggest", + "queryParams": { "query": "$query" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/clearbit.live.spec.ts b/packages/backend/src/adapters/intl/clearbit.live.spec.ts new file mode 100644 index 0000000..f451206 --- /dev/null +++ b/packages/backend/src/adapters/intl/clearbit.live.spec.ts @@ -0,0 +1,16 @@ +import * as adapter from './clearbit.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: Record }; + tools: Array<{ endpointMapping: { path: string } }>; +}; +describe('clearbit adapter — static spec conformance', () => { + it('person.clearbit.com base', () => expect(a.connector.baseUrl).toBe('https://person.clearbit.com')); + it('basic auth with key as user, empty password', () => { + expect(a.connector.authConfig.username).toBe('{{CLEARBIT_API_KEY}}'); + expect(a.connector.authConfig.password).toBe(''); + }); + it('uses per-tool absolute URLs for company/discovery/reveal/autocomplete (different subdomains)', () => { + const absolute = a.tools.filter((t) => t.endpointMapping.path.startsWith('https://')); + expect(absolute.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/backend/src/adapters/intl/gitbook.json b/packages/backend/src/adapters/intl/gitbook.json new file mode 100644 index 0000000..85d8746 --- /dev/null +++ b/packages/backend/src/adapters/intl/gitbook.json @@ -0,0 +1,162 @@ +{ + "slug": "gitbook", + "name": "GitBook", + "description": "Drive GitBook (docs platform) from any AI agent: spaces, pages, content, search, collections. 9 tools, Bearer auth.", + "instructions": "This connector uses the GitBook API v1 (developer.gitbook.com).\n\n**Setup**:\n1. Sign in to https://app.gitbook.com → bottom-left avatar → **Settings → Developer → Personal access tokens → Create new token**.\n2. Copy the token. Set `GITBOOK_API_TOKEN`.\n\n**Authentication**: `Authorization: Bearer ${GITBOOK_API_TOKEN}`.\n\n**Hierarchy**: Organization → Collection → Space → Page (a tree of pages, sub-pages, content).\n\n**Space IDs**: each space has a UUID-like ID. Discover via `gitbook_list_spaces`.\n\n**Pages**: content lives in pages. Each page has id, slug, title, type (document, group, link), revision_id (every change creates a new revision).\n\n**Content format**: GitBook stores content as a JSON document tree (their own AST). Reading is straightforward; writing requires constructing the AST — out of scope for most agent use. For simple writes, use 'GitBook content imports' (markdown imports) — exposed as `gitbook_import_markdown_to_page`.\n\n**Search**: full-text across spaces.\n\n**Pagination**: `?page=N&limit=M` (max 100).\n\n**Out of scope here**: content editing via the AST, change requests, revisions diff, custom domains, integrations management.", + "region": "intl", + "category": "knowledge", + "icon": "gitbook", + "docsUrl": "https://developer.gitbook.com/", + "requiredEnvVars": ["GITBOOK_API_TOKEN"], + "connector": { + "name": "GitBook v1", + "type": "REST", + "baseUrl": "https://api.gitbook.com/v1", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{GITBOOK_API_TOKEN}}" + } + }, + "tools": [ + { + "name": "gitbook_get_me", + "description": "Return the user the token belongs to: id, displayName, email, photoURL.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/user" } + }, + { + "name": "gitbook_list_organizations", + "description": "List organizations the user is a member of.", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Per page." }, + "page": { "type": "string", "description": "Page cursor." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/orgs", + "queryParams": { "limit": "$limit", "page": "$page" } + } + }, + { + "name": "gitbook_list_spaces", + "description": "List spaces in an organization. Each space has id, title, visibility (public/private), urls.", + "parameters": { + "type": "object", + "properties": { + "organizationId": { "type": "string", "description": "Organization ID." }, + "limit": { "type": "integer", "description": "Per page." }, + "page": { "type": "string", "description": "Cursor." } + }, + "required": ["organizationId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/orgs/{organizationId}/spaces", + "queryParams": { "limit": "$limit", "page": "$page" } + } + }, + { + "name": "gitbook_get_space", + "description": "Fetch a single space with full metadata.", + "parameters": { + "type": "object", + "properties": { + "spaceId": { "type": "string", "description": "Space ID." } + }, + "required": ["spaceId"] + }, + "endpointMapping": { "method": "GET", "path": "/spaces/{spaceId}" } + }, + { + "name": "gitbook_get_space_content", + "description": "Get the table of contents / page tree for a space (latest revision).", + "parameters": { + "type": "object", + "properties": { + "spaceId": { "type": "string", "description": "Space ID." } + }, + "required": ["spaceId"] + }, + "endpointMapping": { "method": "GET", "path": "/spaces/{spaceId}/content" } + }, + { + "name": "gitbook_get_page", + "description": "Fetch a page's content (the JSON AST). Use format=document (default) or format=markdown for source.", + "parameters": { + "type": "object", + "properties": { + "spaceId": { "type": "string", "description": "Space ID." }, + "pageId": { "type": "string", "description": "Page ID." }, + "format": { "type": "string", "description": "document (default JSON AST) or markdown." } + }, + "required": ["spaceId", "pageId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/spaces/{spaceId}/content/page/{pageId}", + "queryParams": { "format": "$format" } + } + }, + { + "name": "gitbook_search_content", + "description": "Full-text search across spaces. Returns pages with snippets.", + "parameters": { + "type": "object", + "properties": { + "query": { "type": "string", "description": "Search query." }, + "limit": { "type": "integer", "description": "Per page." }, + "page": { "type": "string", "description": "Cursor." } + }, + "required": ["query"] + }, + "endpointMapping": { + "method": "GET", + "path": "/search", + "queryParams": { + "query": "$query", + "limit": "$limit", + "page": "$page" + } + } + }, + { + "name": "gitbook_list_collections", + "description": "List collections in an organization.", + "parameters": { + "type": "object", + "properties": { + "organizationId": { "type": "string", "description": "Organization ID." } + }, + "required": ["organizationId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/orgs/{organizationId}/collections" + } + }, + { + "name": "gitbook_import_markdown_to_page", + "description": "Import Markdown content into a page (replaces the page's content). The agent can write a page in plain Markdown and push it.", + "parameters": { + "type": "object", + "properties": { + "spaceId": { "type": "string", "description": "Space ID." }, + "pageId": { "type": "string", "description": "Page ID." }, + "content": { "type": "string", "description": "Markdown source." } + }, + "required": ["spaceId", "pageId", "content"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/spaces/{spaceId}/content/page/{pageId}/import", + "bodyMapping": { + "format": "markdown", + "content": "$content" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/gitbook.live.spec.ts b/packages/backend/src/adapters/intl/gitbook.live.spec.ts new file mode 100644 index 0000000..572d48c --- /dev/null +++ b/packages/backend/src/adapters/intl/gitbook.live.spec.ts @@ -0,0 +1,6 @@ +import * as adapter from './gitbook.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string; authConfig: Record } }; +describe('gitbook adapter — static spec conformance', () => { + it('api.gitbook.com/v1', () => expect(a.connector.baseUrl).toBe('https://api.gitbook.com/v1')); + it('Bearer auth', () => expect(a.connector.authType).toBe('BEARER_TOKEN')); +}); diff --git a/packages/backend/src/adapters/intl/snov.json b/packages/backend/src/adapters/intl/snov.json new file mode 100644 index 0000000..0db3ad8 --- /dev/null +++ b/packages/backend/src/adapters/intl/snov.json @@ -0,0 +1,174 @@ +{ + "slug": "snov", + "name": "Snov.io", + "description": "Drive Snov.io (lead enrichment + email finder + outreach) from any AI agent: email finder, verifier, domain search, prospect lists, drip campaigns. 9 tools, OAuth2 client-credentials auth.", + "instructions": "This connector uses the Snov.io API v1 (snov.io/api).\n\n**Setup**:\n1. Sign in to https://app.snov.io → **Account → API**.\n2. Copy the **Client ID** and **Client Secret**.\n3. Run OAuth2 client_credentials flow:\n ```\n POST https://api.snov.io/v1/oauth/access_token\n grant_type=client_credentials&client_id=...&client_secret=...\n ```\n to obtain an access_token. Tokens are valid for 1 hour.\n4. Set `SNOV_ACCESS_TOKEN` to the access token. Refresh externally.\n\n**Authentication**: `Authorization: Bearer ${SNOV_ACCESS_TOKEN}`.\n\n**Credit model**: every find/verify/enrich consumes credits per your plan. `/balance` is free.\n\n**Workflow**: domain search → email finder → email verifier → add prospects to a list → drip campaign.\n\n**Out of scope here**: drip campaign content editing, LinkedIn extension, sender management, deals (Snov.io CRM lite).", + "region": "intl", + "category": "enrichment", + "icon": "snov", + "docsUrl": "https://snov.io/api", + "requiredEnvVars": ["SNOV_ACCESS_TOKEN"], + "connector": { + "name": "Snov.io v1", + "type": "REST", + "baseUrl": "https://api.snov.io/v1", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{SNOV_ACCESS_TOKEN}}" + } + }, + "tools": [ + { + "name": "snov_balance", + "description": "Return remaining credit balance for the account. Free call — use to monitor before bulk operations.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "POST", "path": "/get-balance" } + }, + { + "name": "snov_domain_search", + "description": "Find emails associated with a domain. Returns up to 100 emails per call with name, position, type (personal/generic).", + "parameters": { + "type": "object", + "properties": { + "domain": { "type": "string", "description": "Domain to search." }, + "type": { "type": "string", "description": "personal, generic, all." }, + "limit": { "type": "integer", "description": "Max emails (default 100)." }, + "lastId": { "type": "integer", "description": "Cursor — return emails after this ID." } + }, + "required": ["domain"] + }, + "endpointMapping": { + "method": "POST", + "path": "/get-domain-emails-with-info", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { + "domain": "$domain", + "type": "$type", + "limit": "$limit", + "lastId": "$lastId" + } + } + }, + { + "name": "snov_email_finder", + "description": "Find a person's email by first/last name + domain.", + "parameters": { + "type": "object", + "properties": { + "domain": { "type": "string", "description": "Company domain." }, + "firstName": { "type": "string", "description": "First name." }, + "lastName": { "type": "string", "description": "Last name." } + }, + "required": ["domain", "firstName", "lastName"] + }, + "endpointMapping": { + "method": "POST", + "path": "/get-emails-from-names", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { + "domain": "$domain", + "firstName": "$firstName", + "lastName": "$lastName" + } + } + }, + { + "name": "snov_email_verifier", + "description": "Verify a single email deliverability. Returns status (valid/invalid/catchAll/disposable/webmail/unknown) + smtp/mx checks.", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "Email to verify." } + }, + "required": ["email"] + }, + "endpointMapping": { + "method": "POST", + "path": "/get-emails-verification-status", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { "emails[]": "$email" } + } + }, + { + "name": "snov_email_finder_by_url", + "description": "Find emails from a LinkedIn URL (or other recognized social URL).", + "parameters": { + "type": "object", + "properties": { + "url": { "type": "string", "description": "LinkedIn profile URL." } + }, + "required": ["url"] + }, + "endpointMapping": { + "method": "POST", + "path": "/get-emails-by-url", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { "url": "$url" } + } + }, + { + "name": "snov_list_prospect_lists", + "description": "List your prospect lists (saved leads).", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/get-user-lists" } + }, + { + "name": "snov_create_prospect_list", + "description": "Create a prospect list.", + "parameters": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "List name." } + }, + "required": ["name"] + }, + "endpointMapping": { + "method": "POST", + "path": "/add-user-list", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { "name": "$name" } + } + }, + { + "name": "snov_add_prospect_to_list", + "description": "Add a prospect (person) to a list. Required: email + listId.", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "Prospect email." }, + "fullName": { "type": "string", "description": "Full name." }, + "firstName": { "type": "string", "description": "First name." }, + "lastName": { "type": "string", "description": "Last name." }, + "position": { "type": "string", "description": "Job title." }, + "companyName": { "type": "string", "description": "Company name." }, + "country": { "type": "string", "description": "Country." }, + "listId": { "type": "integer", "description": "Target list ID." }, + "customFields": { "type": "object", "description": "Custom fields object." } + }, + "required": ["email", "listId"] + }, + "endpointMapping": { + "method": "POST", + "path": "/add-prospect-to-list", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { + "email": "$email", + "fullName": "$fullName", + "firstName": "$firstName", + "lastName": "$lastName", + "position": "$position", + "companyName": "$companyName", + "country": "$country", + "listId": "$listId", + "customFields": "$customFields" + } + } + }, + { + "name": "snov_list_drip_campaigns", + "description": "List your drip (outreach) campaigns.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/get-user-campaigns" } + } + ] +} diff --git a/packages/backend/src/adapters/intl/snov.live.spec.ts b/packages/backend/src/adapters/intl/snov.live.spec.ts new file mode 100644 index 0000000..f85ba12 --- /dev/null +++ b/packages/backend/src/adapters/intl/snov.live.spec.ts @@ -0,0 +1,6 @@ +import * as adapter from './snov.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string; authConfig: Record } }; +describe('snov adapter — static spec conformance', () => { + it('api.snov.io/v1', () => expect(a.connector.baseUrl).toBe('https://api.snov.io/v1')); + it('Bearer (OAuth2 client-credentials access token)', () => expect(a.connector.authType).toBe('BEARER_TOKEN')); +}); diff --git a/packages/backend/src/adapters/intl/wufoo.json b/packages/backend/src/adapters/intl/wufoo.json new file mode 100644 index 0000000..02dda79 --- /dev/null +++ b/packages/backend/src/adapters/intl/wufoo.json @@ -0,0 +1,135 @@ +{ + "slug": "wufoo", + "name": "Wufoo", + "description": "Read Wufoo (SurveyMonkey-owned classic form builder) submissions and form definitions from any AI agent. 6 tools, Basic auth, subdomain-templated URL.", + "instructions": "This connector uses the Wufoo API v3 (wufoo.com/api).\n\n**Setup**:\n1. Sign in to Wufoo → top-right avatar → **Account → API Information**.\n2. Note your **API key** and your **subdomain** (e.g. if URL is `acme.wufoo.com` → `acme`).\n3. Set:\n - `WUFOO_SUBDOMAIN` = your subdomain\n - `WUFOO_API_KEY` = the API key\n\n**Authentication**: HTTP Basic with username=API_KEY, password='footastic' (literal — Wufoo's convention).\n\n**Subdomain in base URL**: `https://{{WUFOO_SUBDOMAIN}}.wufoo.com/api/v3`.\n\n**Form/Entry model**: forms (the definitions) contain fields (the questions). Entries (submissions) are field_id→value mappings keyed by short_field_id strings like 'Field1', 'Field2'.\n\n**Pagination**: `?pageStart=N&pageSize=M` (max 100).\n\n**Sort/filter**: pass filters via query params `Filter1=Field1+Is_equal_to+ACME&match=AND` (Wufoo's quirky filter syntax).\n\n**Out of scope here**: form creation (UI-only), reports, users, webhooks (configured in UI).", + "region": "intl", + "category": "forms", + "icon": "wufoo", + "docsUrl": "https://wufoo.com/docs/api/v3/", + "requiredEnvVars": ["WUFOO_SUBDOMAIN", "WUFOO_API_KEY"], + "connector": { + "name": "Wufoo v3", + "type": "REST", + "baseUrl": "https://{{WUFOO_SUBDOMAIN}}.wufoo.com/api/v3", + "authType": "BASIC_AUTH", + "authConfig": { + "username": "{{WUFOO_API_KEY}}", + "password": "footastic" + } + }, + "tools": [ + { + "name": "wufoo_list_forms", + "description": "List all forms on the account. Each form has Hash, Name, Description, EntryCount, DateCreated, DateUpdated, Url, IsPublic.", + "parameters": { + "type": "object", + "properties": { + "pretty": { "type": "boolean", "description": "Pretty-print JSON." }, + "includeTodayCount": { "type": "boolean", "description": "Include today's entry count per form." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/forms.json", + "queryParams": { + "pretty": "$pretty", + "includeTodayCount": "$includeTodayCount" + } + } + }, + { + "name": "wufoo_get_form", + "description": "Fetch a single form's metadata by its Hash.", + "parameters": { + "type": "object", + "properties": { + "formIdentifier": { "type": "string", "description": "Form Hash (e.g. 'x7w1q1') or URL slug." } + }, + "required": ["formIdentifier"] + }, + "endpointMapping": { "method": "GET", "path": "/forms/{formIdentifier}.json" } + }, + { + "name": "wufoo_get_form_fields", + "description": "Get a form's fields (the questions). Returns Title, Type, ID (e.g. 'Field1'), IsRequired, Choices (for picker fields), DefaultVal.", + "parameters": { + "type": "object", + "properties": { + "formIdentifier": { "type": "string", "description": "Form Hash." }, + "system": { "type": "boolean", "description": "If true, also return system fields (e.g. EntryId, DateCreated)." } + }, + "required": ["formIdentifier"] + }, + "endpointMapping": { + "method": "GET", + "path": "/forms/{formIdentifier}/fields.json", + "queryParams": { "system": "$system" } + } + }, + { + "name": "wufoo_list_form_entries", + "description": "List submissions (entries) for a form. Each entry is a row with Field1/Field2/.../EntryId/DateCreated/IP keys.", + "parameters": { + "type": "object", + "properties": { + "formIdentifier": { "type": "string", "description": "Form Hash." }, + "pageStart": { "type": "integer", "description": "0-based offset." }, + "pageSize": { "type": "integer", "description": "Per page (max 100)." }, + "sort": { "type": "string", "description": "Field ID to sort by." }, + "sortDirection": { "type": "string", "description": "ASC or DESC." }, + "system": { "type": "boolean", "description": "Include system fields." }, + "Filter1": { "type": "string", "description": "Filter expression: 'Field1+Is_equal_to+ACME'." }, + "match": { "type": "string", "description": "AND or OR (for multiple filters)." } + }, + "required": ["formIdentifier"] + }, + "endpointMapping": { + "method": "GET", + "path": "/forms/{formIdentifier}/entries.json", + "queryParams": { + "pageStart": "$pageStart", + "pageSize": "$pageSize", + "sort": "$sort", + "sortDirection": "$sortDirection", + "system": "$system", + "Filter1": "$Filter1", + "match": "$match" + } + } + }, + { + "name": "wufoo_count_form_entries", + "description": "Return just the entry count for a form (cheap — no entry payload).", + "parameters": { + "type": "object", + "properties": { + "formIdentifier": { "type": "string", "description": "Form Hash." } + }, + "required": ["formIdentifier"] + }, + "endpointMapping": { + "method": "GET", + "path": "/forms/{formIdentifier}/entries/count.json" + } + }, + { + "name": "wufoo_create_form_entry", + "description": "Submit a new entry to a form (programmatic submission). Body is keyed by Field ID strings: {Field1:'value', Field2:'value', ...}. Get field IDs from wufoo_get_form_fields.", + "parameters": { + "type": "object", + "properties": { + "formIdentifier": { "type": "string", "description": "Form Hash." }, + "fields": { "type": "object", "description": "Map of {Field1: value, Field2: value, ...}." } + }, + "required": ["formIdentifier", "fields"] + }, + "endpointMapping": { + "method": "POST", + "path": "/forms/{formIdentifier}/entries.json", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { "fields": "$fields" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/wufoo.live.spec.ts b/packages/backend/src/adapters/intl/wufoo.live.spec.ts new file mode 100644 index 0000000..8bb06b2 --- /dev/null +++ b/packages/backend/src/adapters/intl/wufoo.live.spec.ts @@ -0,0 +1,12 @@ +import * as adapter from './wufoo.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string; authConfig: Record } }; +describe('wufoo adapter — static spec conformance', () => { + it('subdomain-templated base URL', () => { + expect(a.connector.baseUrl).toBe('https://{{WUFOO_SUBDOMAIN}}.wufoo.com/api/v3'); + }); + it('Basic with key + literal "footastic" password (Wufoo convention)', () => { + expect(a.connector.authType).toBe('BASIC_AUTH'); + expect(a.connector.authConfig.username).toBe('{{WUFOO_API_KEY}}'); + expect(a.connector.authConfig.password).toBe('footastic'); + }); +}); From bc2e2d56e6d78d615e02dad0eacf753fd44436d5 Mon Sep 17 00:00:00 2001 From: Matteo Date: Wed, 20 May 2026 13:15:56 +0200 Subject: [PATCH 14/19] connectors: add Magento, Etsy, YouTube Data, Insightly, Fillout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 11 — e-commerce + social + CRM + forms continuation. - Magento 2 / Adobe Commerce: 12 tools — products with SKU as primary key, categories tree, stock update, orders with cancel, customers. Bearer admin-token. Heavy use of Magento's searchCriteria filter_groups DSL via templated query params. - Etsy Open API v3: 9 tools — user/shops, listings active/single, receipts with shipping status filters, reviews, listings by section. DUAL AUTH — x-api-key header (App key) + Authorization Bearer (OAuth user token). Engine extraHeaders pattern. - YouTube Data v3: 9 tools — search (100 quota units!), videos, channels, playlists, playlist items, comment threads, video categories, captions. QUERY_AUTH with key. Documents quota cost model per endpoint. - Insightly v3.1: 12 tools — contacts/organisations/leads/ opportunities CRUD, pipelines, projects, tasks. BASIC_AUTH with pod-templated baseUrl (na1/eu2/...). - Fillout v1: 6 tools — forms, submissions list+get+create+delete. Bearer auth. Modern Typeform alternative. Catalog: 100 adapters (60/81 of the greenfield batch done, ~74%). --- packages/backend/src/adapters/catalog.ts | 10 + packages/backend/src/adapters/intl/etsy.json | 199 ++++++++++++ .../src/adapters/intl/etsy.live.spec.ts | 9 + .../backend/src/adapters/intl/fillout.json | 125 ++++++++ .../src/adapters/intl/fillout.live.spec.ts | 6 + .../backend/src/adapters/intl/insightly.json | 294 ++++++++++++++++++ .../src/adapters/intl/insightly.live.spec.ts | 11 + .../backend/src/adapters/intl/magento.json | 266 ++++++++++++++++ .../src/adapters/intl/magento.live.spec.ts | 8 + .../src/adapters/intl/youtube-data.json | 256 +++++++++++++++ .../adapters/intl/youtube-data.live.spec.ts | 9 + 11 files changed, 1193 insertions(+) create mode 100644 packages/backend/src/adapters/intl/etsy.json create mode 100644 packages/backend/src/adapters/intl/etsy.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/fillout.json create mode 100644 packages/backend/src/adapters/intl/fillout.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/insightly.json create mode 100644 packages/backend/src/adapters/intl/insightly.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/magento.json create mode 100644 packages/backend/src/adapters/intl/magento.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/youtube-data.json create mode 100644 packages/backend/src/adapters/intl/youtube-data.live.spec.ts diff --git a/packages/backend/src/adapters/catalog.ts b/packages/backend/src/adapters/catalog.ts index 541f569..c96fdc3 100644 --- a/packages/backend/src/adapters/catalog.ts +++ b/packages/backend/src/adapters/catalog.ts @@ -50,7 +50,9 @@ import * as copper from './intl/copper.json'; import * as crisp from './intl/crisp.json'; import * as discordBot from './intl/discord-bot.json'; import * as drip from './intl/drip.json'; +import * as etsy from './intl/etsy.json'; import * as fathom from './intl/fathom.json'; +import * as fillout from './intl/fillout.json'; import * as freshdesk from './intl/freshdesk.json'; import * as front from './intl/front.json'; import * as ghost from './intl/ghost.json'; @@ -58,10 +60,12 @@ import * as gitbook from './intl/gitbook.json'; import * as heap from './intl/heap.json'; import * as helpScout from './intl/help-scout.json'; import * as hunter from './intl/hunter.json'; +import * as insightly from './intl/insightly.json'; import * as klaviyo from './intl/klaviyo.json'; import * as lemlist from './intl/lemlist.json'; import * as lemonsqueezy from './intl/lemonsqueezy.json'; import * as loops from './intl/loops.json'; +import * as magento from './intl/magento.json'; import * as mailchimp from './intl/mailchimp.json'; import * as mapbox from './intl/mapbox.json'; import * as mintlify from './intl/mintlify.json'; @@ -89,6 +93,7 @@ import * as whatsappBusiness from './intl/whatsapp-business.json'; import * as woocommerce from './intl/woocommerce.json'; import * as wordpress from './intl/wordpress.json'; import * as wufoo from './intl/wufoo.json'; +import * as youtubeData from './intl/youtube-data.json'; import * as zendesk from './intl/zendesk.json'; import * as mercadoLibre from './br/mercado-libre.json'; import * as razorpay from './in/razorpay.json'; @@ -217,7 +222,9 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ crisp as unknown as AdapterDefinition, discordBot as unknown as AdapterDefinition, drip as unknown as AdapterDefinition, + etsy as unknown as AdapterDefinition, fathom as unknown as AdapterDefinition, + fillout as unknown as AdapterDefinition, freshdesk as unknown as AdapterDefinition, front as unknown as AdapterDefinition, ghost as unknown as AdapterDefinition, @@ -225,10 +232,12 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ heap as unknown as AdapterDefinition, helpScout as unknown as AdapterDefinition, hunter as unknown as AdapterDefinition, + insightly as unknown as AdapterDefinition, klaviyo as unknown as AdapterDefinition, lemlist as unknown as AdapterDefinition, lemonsqueezy as unknown as AdapterDefinition, loops as unknown as AdapterDefinition, + magento as unknown as AdapterDefinition, mailchimp as unknown as AdapterDefinition, mapbox as unknown as AdapterDefinition, mintlify as unknown as AdapterDefinition, @@ -256,6 +265,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ woocommerce as unknown as AdapterDefinition, wordpress as unknown as AdapterDefinition, wufoo as unknown as AdapterDefinition, + youtubeData as unknown as AdapterDefinition, zendesk as unknown as AdapterDefinition, mercadoLibre as unknown as AdapterDefinition, razorpay as unknown as AdapterDefinition, diff --git a/packages/backend/src/adapters/intl/etsy.json b/packages/backend/src/adapters/intl/etsy.json new file mode 100644 index 0000000..8830676 --- /dev/null +++ b/packages/backend/src/adapters/intl/etsy.json @@ -0,0 +1,199 @@ +{ + "slug": "etsy", + "name": "Etsy", + "description": "Read Etsy shop data (listings, transactions, receipts, reviews) from any AI agent. 9 tools, OAuth2 + x-api-key dual auth.", + "instructions": "This connector uses the Etsy Open API v3 (developers.etsy.com).\n\n**Setup**:\n1. Register an app at https://www.etsy.com/developers/your-apps → **Create New App**.\n2. Note the **Keystring** (this is your x-api-key) and **Shared secret**.\n3. Run OAuth2 PKCE flow with scopes `transactions_r listings_r email_r shops_r` (or write-scopes if needed).\n4. Get the access token (1 hour expiry) + refresh token (long-lived).\n5. Set:\n - `ETSY_API_KEY` = the Keystring (a.k.a. x-api-key)\n - `ETSY_ACCESS_TOKEN` = the OAuth2 access token\n\n**Authentication**: BOTH headers needed simultaneously:\n - `x-api-key: ${ETSY_API_KEY}`\n - `Authorization: Bearer ${ETSY_ACCESS_TOKEN}`\nThe adapter sets the api-key via API_KEY profile and adds Authorization via extraHeaders (uses the engine's extraHeaders support).\n\n**Shop ID**: every account has a shop_id. Get it from `etsy_get_authenticated_user` then `etsy_get_user_shops`.\n\n**Pagination**: `?limit=N&offset=M` (limit max 100).\n\n**Rate limits**: 10k req/24h per app. On 429 back off.\n\n**Out of scope here**: listing image uploads, write-scope listing creation (Etsy's listing publish flow is multi-step), payment account, billing, taxonomy edits.", + "region": "intl", + "category": "e-commerce", + "icon": "etsy", + "docsUrl": "https://developers.etsy.com/documentation/", + "requiredEnvVars": ["ETSY_API_KEY", "ETSY_ACCESS_TOKEN"], + "connector": { + "name": "Etsy Open API v3", + "type": "REST", + "baseUrl": "https://openapi.etsy.com/v3/application", + "authType": "API_KEY", + "authConfig": { + "headerName": "x-api-key", + "apiKey": "{{ETSY_API_KEY}}", + "extraHeaders": { + "Authorization": "Bearer {{ETSY_ACCESS_TOKEN}}" + } + } + }, + "tools": [ + { + "name": "etsy_get_authenticated_user", + "description": "Return the user the OAuth token belongs to. Returns user_id, login_name, primary_email.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/users/me" } + }, + { + "name": "etsy_get_user_shops", + "description": "List shops owned by the user. Returns shop_id, shop_name, currency_code, languages, login_name, last_updated_tsz, listing_active_count.", + "parameters": { + "type": "object", + "properties": { + "user_id": { "type": "integer", "description": "User ID." } + }, + "required": ["user_id"] + }, + "endpointMapping": { "method": "GET", "path": "/users/{user_id}/shops" } + }, + { + "name": "etsy_get_shop", + "description": "Fetch one shop by shop_id with full details (announcement, sale message, etc.).", + "parameters": { + "type": "object", + "properties": { + "shop_id": { "type": "integer", "description": "Shop ID." } + }, + "required": ["shop_id"] + }, + "endpointMapping": { "method": "GET", "path": "/shops/{shop_id}" } + }, + { + "name": "etsy_get_shop_listings_active", + "description": "List active listings in a shop.", + "parameters": { + "type": "object", + "properties": { + "shop_id": { "type": "integer", "description": "Shop ID." }, + "limit": { "type": "integer", "description": "Per page (max 100)." }, + "offset": { "type": "integer", "description": "Pagination offset." }, + "sort_on": { "type": "string", "description": "created, price, updated, score." }, + "sort_order": { "type": "string", "description": "asc, ascending, desc, descending, up, down." }, + "keywords": { "type": "string", "description": "Substring search on title." } + }, + "required": ["shop_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/shops/{shop_id}/listings/active", + "queryParams": { + "limit": "$limit", + "offset": "$offset", + "sort_on": "$sort_on", + "sort_order": "$sort_order", + "keywords": "$keywords" + } + } + }, + { + "name": "etsy_get_listing", + "description": "Fetch one listing by listing_id with full details.", + "parameters": { + "type": "object", + "properties": { + "listing_id": { "type": "integer", "description": "Listing ID." }, + "includes": { "type": "string", "description": "Comma-separated: Shipping, Images, Shop, User, Translations, Inventory, Videos." } + }, + "required": ["listing_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/listings/{listing_id}", + "queryParams": { "includes": "$includes" } + } + }, + { + "name": "etsy_get_shop_receipts", + "description": "List orders (receipts) for the shop.", + "parameters": { + "type": "object", + "properties": { + "shop_id": { "type": "integer", "description": "Shop ID." }, + "min_created": { "type": "integer", "description": "Unix timestamp." }, + "max_created": { "type": "integer", "description": "Unix timestamp." }, + "min_last_modified": { "type": "integer", "description": "Unix timestamp." }, + "max_last_modified": { "type": "integer", "description": "Unix timestamp." }, + "limit": { "type": "integer", "description": "Per page (max 100)." }, + "offset": { "type": "integer", "description": "Offset." }, + "sort_on": { "type": "string", "description": "created, updated, receipt_id." }, + "sort_order": { "type": "string", "description": "asc, desc." }, + "was_paid": { "type": "boolean", "description": "Filter paid status." }, + "was_shipped": { "type": "boolean", "description": "Filter shipped status." } + }, + "required": ["shop_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/shops/{shop_id}/receipts", + "queryParams": { + "min_created": "$min_created", + "max_created": "$max_created", + "min_last_modified": "$min_last_modified", + "max_last_modified": "$max_last_modified", + "limit": "$limit", + "offset": "$offset", + "sort_on": "$sort_on", + "sort_order": "$sort_order", + "was_paid": "$was_paid", + "was_shipped": "$was_shipped" + } + } + }, + { + "name": "etsy_get_shop_receipt", + "description": "Fetch one receipt with buyer info, transactions[], shipping address.", + "parameters": { + "type": "object", + "properties": { + "shop_id": { "type": "integer", "description": "Shop ID." }, + "receipt_id": { "type": "integer", "description": "Receipt ID." } + }, + "required": ["shop_id", "receipt_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/shops/{shop_id}/receipts/{receipt_id}" + } + }, + { + "name": "etsy_get_shop_reviews", + "description": "List reviews (transactions with feedback) for the shop.", + "parameters": { + "type": "object", + "properties": { + "shop_id": { "type": "integer", "description": "Shop ID." }, + "limit": { "type": "integer", "description": "Per page." }, + "offset": { "type": "integer", "description": "Offset." }, + "min_created": { "type": "integer", "description": "Unix timestamp lower bound." } + }, + "required": ["shop_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/shops/{shop_id}/reviews", + "queryParams": { + "limit": "$limit", + "offset": "$offset", + "min_created": "$min_created" + } + } + }, + { + "name": "etsy_get_listings_by_shop_section", + "description": "List listings filtered by a shop section.", + "parameters": { + "type": "object", + "properties": { + "shop_id": { "type": "integer", "description": "Shop ID." }, + "shop_section_ids": { "type": "string", "description": "Comma-separated section IDs." }, + "limit": { "type": "integer", "description": "Per page." }, + "offset": { "type": "integer", "description": "Offset." } + }, + "required": ["shop_id", "shop_section_ids"] + }, + "endpointMapping": { + "method": "GET", + "path": "/shops/{shop_id}/shop-sections/listings/active", + "queryParams": { + "shop_section_ids": "$shop_section_ids", + "limit": "$limit", + "offset": "$offset" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/etsy.live.spec.ts b/packages/backend/src/adapters/intl/etsy.live.spec.ts new file mode 100644 index 0000000..7ced169 --- /dev/null +++ b/packages/backend/src/adapters/intl/etsy.live.spec.ts @@ -0,0 +1,9 @@ +import * as adapter from './etsy.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string; authConfig: any } }; +describe('etsy adapter — static spec conformance', () => { + it('openapi.etsy.com/v3/application', () => expect(a.connector.baseUrl).toBe('https://openapi.etsy.com/v3/application')); + it('x-api-key header + extraHeaders for Bearer', () => { + expect(a.connector.authConfig.headerName).toBe('x-api-key'); + expect(a.connector.authConfig.extraHeaders.Authorization).toBe('Bearer {{ETSY_ACCESS_TOKEN}}'); + }); +}); diff --git a/packages/backend/src/adapters/intl/fillout.json b/packages/backend/src/adapters/intl/fillout.json new file mode 100644 index 0000000..8816618 --- /dev/null +++ b/packages/backend/src/adapters/intl/fillout.json @@ -0,0 +1,125 @@ +{ + "slug": "fillout", + "name": "Fillout", + "description": "Read Fillout (modern form/survey builder) form definitions and submissions from any AI agent. 6 tools, Bearer auth.", + "instructions": "This connector uses the Fillout API v1 (fillout.com/help/developers).\n\n**Setup**:\n1. Sign in to https://fillout.com → top-right avatar → **Settings → Developer → API → Create API key**.\n2. Set `FILLOUT_API_KEY`.\n\n**Authentication**: `Authorization: Bearer ${FILLOUT_API_KEY}`.\n\n**Form IDs**: each form has a 32-character ID (visible in form URL). Use `fillout_list_forms` to discover.\n\n**Submission shape**: each submission has questions[] array of {id, name, type, value}. Cross-reference with form questions for human-readable names.\n\n**Pagination**: `?limit=N&offset=M`. Submissions endpoint supports `?afterDate=ISO&beforeDate=ISO`.\n\n**Out of scope here**: form creation (UI), file uploads, webhook subscription management.", + "region": "intl", + "category": "forms", + "icon": "fillout", + "docsUrl": "https://fillout.com/help/developers", + "requiredEnvVars": ["FILLOUT_API_KEY"], + "connector": { + "name": "Fillout v1", + "type": "REST", + "baseUrl": "https://api.fillout.com/v1/api", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{FILLOUT_API_KEY}}" + } + }, + "tools": [ + { + "name": "fillout_list_forms", + "description": "List forms in the workspace.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/forms" } + }, + { + "name": "fillout_get_form", + "description": "Fetch a form's full definition: questions[] (id, name, type, options), settings.", + "parameters": { + "type": "object", + "properties": { + "formId": { "type": "string", "description": "Form ID." } + }, + "required": ["formId"] + }, + "endpointMapping": { "method": "GET", "path": "/forms/{formId}" } + }, + { + "name": "fillout_list_submissions", + "description": "List submissions for a form, with optional date range filter.", + "parameters": { + "type": "object", + "properties": { + "formId": { "type": "string", "description": "Form ID." }, + "limit": { "type": "integer", "description": "Per page (default 50, max 150)." }, + "offset": { "type": "integer", "description": "Pagination offset." }, + "afterDate": { "type": "string", "description": "ISO 8601 — submissions after this." }, + "beforeDate": { "type": "string", "description": "ISO 8601 — submissions before this." }, + "status": { "type": "string", "description": "finished, in_progress." }, + "includeEditLink": { "type": "boolean", "description": "If true, include the edit link for each submission." }, + "search": { "type": "string", "description": "Substring search across answer values." }, + "sort": { "type": "string", "description": "asc or desc by submitted time." } + }, + "required": ["formId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/forms/{formId}/submissions", + "queryParams": { + "limit": "$limit", + "offset": "$offset", + "afterDate": "$afterDate", + "beforeDate": "$beforeDate", + "status": "$status", + "includeEditLink": "$includeEditLink", + "search": "$search", + "sort": "$sort" + } + } + }, + { + "name": "fillout_get_submission", + "description": "Fetch one submission with full answers.", + "parameters": { + "type": "object", + "properties": { + "formId": { "type": "string", "description": "Form ID." }, + "submissionId": { "type": "string", "description": "Submission ID." }, + "includeEditLink": { "type": "boolean", "description": "Include edit link." } + }, + "required": ["formId", "submissionId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/forms/{formId}/submissions/{submissionId}", + "queryParams": { "includeEditLink": "$includeEditLink" } + } + }, + { + "name": "fillout_delete_submissions", + "description": "Delete one or more submissions by ID.", + "parameters": { + "type": "object", + "properties": { + "formId": { "type": "string", "description": "Form ID." }, + "submissionIds": { "type": "array", "description": "Array of submission IDs." } + }, + "required": ["formId", "submissionIds"] + }, + "endpointMapping": { + "method": "DELETE", + "path": "/forms/{formId}/submissions", + "bodyMapping": { "submissionIds": "$submissionIds" } + } + }, + { + "name": "fillout_create_submission", + "description": "Programmatically create a submission. submissions array has {questions:[{id, value}], hiddenFields?, urlParameters?}.", + "parameters": { + "type": "object", + "properties": { + "formId": { "type": "string", "description": "Form ID." }, + "submissions": { "type": "array", "description": "Array of submissions: [{questions:[{id, value}], submissionTime?, hiddenFields?, urlParameters?}]." } + }, + "required": ["formId", "submissions"] + }, + "endpointMapping": { + "method": "POST", + "path": "/forms/{formId}/submissions", + "bodyMapping": { "submissions": "$submissions" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/fillout.live.spec.ts b/packages/backend/src/adapters/intl/fillout.live.spec.ts new file mode 100644 index 0000000..22063f2 --- /dev/null +++ b/packages/backend/src/adapters/intl/fillout.live.spec.ts @@ -0,0 +1,6 @@ +import * as adapter from './fillout.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string; authConfig: Record } }; +describe('fillout adapter — static spec conformance', () => { + it('api.fillout.com/v1/api', () => expect(a.connector.baseUrl).toBe('https://api.fillout.com/v1/api')); + it('Bearer auth', () => expect(a.connector.authType).toBe('BEARER_TOKEN')); +}); diff --git a/packages/backend/src/adapters/intl/insightly.json b/packages/backend/src/adapters/intl/insightly.json new file mode 100644 index 0000000..87f0f21 --- /dev/null +++ b/packages/backend/src/adapters/intl/insightly.json @@ -0,0 +1,294 @@ +{ + "slug": "insightly", + "name": "Insightly", + "description": "Drive Insightly (CRM + project management) from any AI agent: contacts, organisations, leads, opportunities, projects, tasks. 12 tools, Basic auth.", + "instructions": "This connector uses the Insightly REST API v3.1 (api.insightly.com/v3.1).\n\n**Setup**:\n1. Sign in to Insightly → bottom-left avatar → **User Settings → API Key**.\n2. Copy the key. Set `INSIGHTLY_API_KEY`.\n3. Note the **API URL pod** — visible in the same screen as the API key (e.g. `api.na1.insightly.com` for North America #1). Set `INSIGHTLY_POD` (just the pod part, e.g. `na1`, `eu2`).\n\n**Authentication**: HTTP Basic with username=API_KEY, password=empty.\n\n**Pod-specific URL**: `https://api.{{INSIGHTLY_POD}}.insightly.com/v3.1`. Wrong pod → 404.\n\n**Lead vs Contact**: Insightly has both. Leads are unqualified (no opportunity yet); contacts are people in opportunities or just relationships.\n\n**Custom fields**: stored under `CUSTOMFIELDS` array of `{FIELD_NAME, FIELD_VALUE, CUSTOM_FIELD_ID}`.\n\n**Pipeline / Stage IDs**: opportunities live in pipelines with stages. Discover via `insightly_list_pipelines` + `insightly_list_pipeline_stages`.\n\n**Pagination**: `?skip=N&top=M` (max 500 per page).\n\n**Out of scope here**: emails CRUD, tasks recurrence, file attachments, custom-object schemas, project templates.", + "region": "intl", + "category": "crm", + "icon": "insightly", + "docsUrl": "https://api.insightly.com/v3.1/Help", + "requiredEnvVars": ["INSIGHTLY_API_KEY", "INSIGHTLY_POD"], + "connector": { + "name": "Insightly v3.1", + "type": "REST", + "baseUrl": "https://api.{{INSIGHTLY_POD}}.insightly.com/v3.1", + "authType": "BASIC_AUTH", + "authConfig": { + "username": "{{INSIGHTLY_API_KEY}}", + "password": "" + } + }, + "tools": [ + { + "name": "insightly_get_current_user", + "description": "Return the user the API key belongs to (whoami).", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/Users/me" } + }, + { + "name": "insightly_list_contacts", + "description": "List contacts with filter/sort.", + "parameters": { + "type": "object", + "properties": { + "skip": { "type": "integer", "description": "Pagination offset." }, + "top": { "type": "integer", "description": "Per page (max 500)." }, + "brief": { "type": "boolean", "description": "true = compact response without long-text fields." }, + "updated_after_utc": { "type": "string", "description": "ISO 8601 — contacts modified after." }, + "field_name": { "type": "string", "description": "Filter field name." }, + "field_value": { "type": "string", "description": "Filter value." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/Contacts", + "queryParams": { + "skip": "$skip", + "top": "$top", + "brief": "$brief", + "updated_after_utc": "$updated_after_utc", + "field_name": "$field_name", + "field_value": "$field_value" + } + } + }, + { + "name": "insightly_get_contact", + "description": "Fetch one contact by ID.", + "parameters": { + "type": "object", + "properties": { + "contactId": { "type": "integer", "description": "Contact ID." } + }, + "required": ["contactId"] + }, + "endpointMapping": { "method": "GET", "path": "/Contacts/{contactId}" } + }, + { + "name": "insightly_create_contact", + "description": "Create a contact. Required: FIRST_NAME or LAST_NAME.", + "parameters": { + "type": "object", + "properties": { + "FIRST_NAME": { "type": "string", "description": "First name." }, + "LAST_NAME": { "type": "string", "description": "Last name." }, + "EMAIL_ADDRESS": { "type": "string", "description": "Email." }, + "TITLE": { "type": "string", "description": "Job title." }, + "PHONE": { "type": "string", "description": "Phone." }, + "PHONE_MOBILE": { "type": "string", "description": "Mobile phone." }, + "ORGANISATION_ID": { "type": "integer", "description": "Linked organisation." }, + "OWNER_USER_ID": { "type": "integer", "description": "Owner user." }, + "BACKGROUND": { "type": "string", "description": "Notes." }, + "CUSTOMFIELDS": { "type": "array", "description": "[{FIELD_NAME, FIELD_VALUE, CUSTOM_FIELD_ID}]." }, + "TAGS": { "type": "array", "description": "[{TAG_NAME}]." } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/Contacts", + "bodyMapping": { + "FIRST_NAME": "$FIRST_NAME", + "LAST_NAME": "$LAST_NAME", + "EMAIL_ADDRESS": "$EMAIL_ADDRESS", + "TITLE": "$TITLE", + "PHONE": "$PHONE", + "PHONE_MOBILE": "$PHONE_MOBILE", + "ORGANISATION_ID": "$ORGANISATION_ID", + "OWNER_USER_ID": "$OWNER_USER_ID", + "BACKGROUND": "$BACKGROUND", + "CUSTOMFIELDS": "$CUSTOMFIELDS", + "TAGS": "$TAGS" + } + } + }, + { + "name": "insightly_list_organisations", + "description": "List organisations (companies).", + "parameters": { + "type": "object", + "properties": { + "skip": { "type": "integer", "description": "Offset." }, + "top": { "type": "integer", "description": "Per page." }, + "brief": { "type": "boolean", "description": "Compact response." }, + "field_name": { "type": "string", "description": "Filter field." }, + "field_value": { "type": "string", "description": "Filter value." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/Organisations", + "queryParams": { + "skip": "$skip", + "top": "$top", + "brief": "$brief", + "field_name": "$field_name", + "field_value": "$field_value" + } + } + }, + { + "name": "insightly_list_leads", + "description": "List leads (unqualified prospects).", + "parameters": { + "type": "object", + "properties": { + "skip": { "type": "integer", "description": "Offset." }, + "top": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/Leads", + "queryParams": { "skip": "$skip", "top": "$top" } + } + }, + { + "name": "insightly_create_lead", + "description": "Create a lead.", + "parameters": { + "type": "object", + "properties": { + "FIRST_NAME": { "type": "string", "description": "First name." }, + "LAST_NAME": { "type": "string", "description": "Last name." }, + "ORGANISATION_NAME": { "type": "string", "description": "Company name (free text — not linked to organisations record)." }, + "EMAIL": { "type": "string", "description": "Email." }, + "PHONE_NUMBER": { "type": "string", "description": "Phone." }, + "TITLE": { "type": "string", "description": "Job title." }, + "LEAD_SOURCE_ID": { "type": "integer", "description": "Lead source ID." }, + "LEAD_STATUS_ID": { "type": "integer", "description": "Lead status ID." }, + "OWNER_USER_ID": { "type": "integer", "description": "Owner." } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/Leads", + "bodyMapping": { + "FIRST_NAME": "$FIRST_NAME", + "LAST_NAME": "$LAST_NAME", + "ORGANISATION_NAME": "$ORGANISATION_NAME", + "EMAIL": "$EMAIL", + "PHONE_NUMBER": "$PHONE_NUMBER", + "TITLE": "$TITLE", + "LEAD_SOURCE_ID": "$LEAD_SOURCE_ID", + "LEAD_STATUS_ID": "$LEAD_STATUS_ID", + "OWNER_USER_ID": "$OWNER_USER_ID" + } + } + }, + { + "name": "insightly_list_opportunities", + "description": "List opportunities.", + "parameters": { + "type": "object", + "properties": { + "skip": { "type": "integer", "description": "Offset." }, + "top": { "type": "integer", "description": "Per page." }, + "field_name": { "type": "string", "description": "Filter field." }, + "field_value": { "type": "string", "description": "Filter value." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/Opportunities", + "queryParams": { + "skip": "$skip", + "top": "$top", + "field_name": "$field_name", + "field_value": "$field_value" + } + } + }, + { + "name": "insightly_create_opportunity", + "description": "Create an opportunity. Required: OPPORTUNITY_NAME.", + "parameters": { + "type": "object", + "properties": { + "OPPORTUNITY_NAME": { "type": "string", "description": "Name." }, + "OPPORTUNITY_DETAILS": { "type": "string", "description": "Description." }, + "OPPORTUNITY_STATE": { "type": "string", "description": "OPEN, WON, LOST, SUSPENDED, ABANDONED." }, + "PIPELINE_ID": { "type": "integer", "description": "Pipeline ID." }, + "STAGE_ID": { "type": "integer", "description": "Stage ID within pipeline." }, + "BID_AMOUNT": { "type": "number", "description": "Deal value." }, + "BID_CURRENCY": { "type": "string", "description": "ISO 4217 code." }, + "FORECAST_CLOSE_DATE": { "type": "string", "description": "ISO 8601." }, + "PROBABILITY": { "type": "integer", "description": "0-100." }, + "OWNER_USER_ID": { "type": "integer", "description": "Owner." }, + "RESPONSIBLE_USER_ID": { "type": "integer", "description": "Responsible user." } + }, + "required": ["OPPORTUNITY_NAME"] + }, + "endpointMapping": { + "method": "POST", + "path": "/Opportunities", + "bodyMapping": { + "OPPORTUNITY_NAME": "$OPPORTUNITY_NAME", + "OPPORTUNITY_DETAILS": "$OPPORTUNITY_DETAILS", + "OPPORTUNITY_STATE": "$OPPORTUNITY_STATE", + "PIPELINE_ID": "$PIPELINE_ID", + "STAGE_ID": "$STAGE_ID", + "BID_AMOUNT": "$BID_AMOUNT", + "BID_CURRENCY": "$BID_CURRENCY", + "FORECAST_CLOSE_DATE": "$FORECAST_CLOSE_DATE", + "PROBABILITY": "$PROBABILITY", + "OWNER_USER_ID": "$OWNER_USER_ID", + "RESPONSIBLE_USER_ID": "$RESPONSIBLE_USER_ID" + } + } + }, + { + "name": "insightly_list_pipelines", + "description": "List pipelines + their stages (FOR_OPPORTUNITIES or FOR_PROJECTS).", + "parameters": { + "type": "object", + "properties": { + "skip": { "type": "integer", "description": "Offset." }, + "top": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/Pipelines", + "queryParams": { "skip": "$skip", "top": "$top" } + } + }, + { + "name": "insightly_list_projects", + "description": "List projects.", + "parameters": { + "type": "object", + "properties": { + "skip": { "type": "integer", "description": "Offset." }, + "top": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/Projects", + "queryParams": { "skip": "$skip", "top": "$top" } + } + }, + { + "name": "insightly_list_tasks", + "description": "List tasks (CRM activities).", + "parameters": { + "type": "object", + "properties": { + "skip": { "type": "integer", "description": "Offset." }, + "top": { "type": "integer", "description": "Per page." }, + "status": { "type": "string", "description": "NOT STARTED, IN PROGRESS, COMPLETED, DEFERRED, WAITING." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/Tasks", + "queryParams": { + "skip": "$skip", + "top": "$top", + "field_name": "STATUS", + "field_value": "$status" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/insightly.live.spec.ts b/packages/backend/src/adapters/intl/insightly.live.spec.ts new file mode 100644 index 0000000..e63967f --- /dev/null +++ b/packages/backend/src/adapters/intl/insightly.live.spec.ts @@ -0,0 +1,11 @@ +import * as adapter from './insightly.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string; authConfig: Record } }; +describe('insightly adapter — static spec conformance', () => { + it('pod-templated base URL', () => { + expect(a.connector.baseUrl).toBe('https://api.{{INSIGHTLY_POD}}.insightly.com/v3.1'); + }); + it('Basic auth with key as user', () => { + expect(a.connector.authConfig.username).toBe('{{INSIGHTLY_API_KEY}}'); + expect(a.connector.authConfig.password).toBe(''); + }); +}); diff --git a/packages/backend/src/adapters/intl/magento.json b/packages/backend/src/adapters/intl/magento.json new file mode 100644 index 0000000..d4c76e1 --- /dev/null +++ b/packages/backend/src/adapters/intl/magento.json @@ -0,0 +1,266 @@ +{ + "slug": "magento", + "name": "Magento (Adobe Commerce)", + "description": "Drive Magento 2 / Adobe Commerce from any AI agent: products, categories, orders, customers, stock. 12 tools, Bearer admin-token auth, per-store base URL.", + "instructions": "This connector uses the Magento 2 REST API (devdocs.magento.com).\n\n**Setup**:\n1. As Magento admin → **System → Integrations → Add New Integration**.\n2. Name + email, then on the API tab pick scopes: at minimum Catalog (Products + Categories + Stock), Customers, Sales (Orders + Invoices + Shipments).\n3. **Activate** the integration → grant access. Magento generates an **Access Token** (this is the long-lived bearer token).\n4. Set:\n - `MAGENTO_BASE_URL` = your storefront base URL (e.g. `https://shop.example.com`)\n - `MAGENTO_ACCESS_TOKEN` = the integration access token\n - Optionally `MAGENTO_STORE_VIEW` if you want a specific store view (default 'default' or use 'all')\n\n**Authentication**: `Authorization: Bearer ${MAGENTO_ACCESS_TOKEN}`.\n\n**Path prefix**: `/rest/{store_view}/V1/...`. The adapter uses `/rest/default/V1/` as default — if you need to target a different store view, replace baseUrl accordingly or pass `?storeCode=` (Magento accepts both).\n\n**Search criteria DSL**: Magento has a unique multi-key search syntax for list endpoints:\n```\n?searchCriteria[filter_groups][0][filters][0][field]=status\n&searchCriteria[filter_groups][0][filters][0][value]=1\n&searchCriteria[filter_groups][0][filters][0][condition_type]=eq\n&searchCriteria[pageSize]=20\n&searchCriteria[currentPage]=1\n```\nIt's verbose but powerful. The adapter exposes the most common filters as flat params and lets you pass arbitrary criteria via `searchCriteria_raw` for advanced cases.\n\n**SKU as primary key**: most product endpoints use SKU (not ID) in the URL: `/V1/products/{sku}`.\n\n**Rate limits**: not enforced by Magento core — depends on hosting. On 429 back off.\n\n**Out of scope here**: GraphQL endpoint (separate), Inventory MSI deep features, B2B/Commerce-only features (shared catalogs, company hierarchies), CMS pages/blocks, sales rules editing.", + "region": "intl", + "category": "e-commerce", + "icon": "magento", + "docsUrl": "https://devdocs.magento.com/guides/v2.4/rest/bk-rest.html", + "requiredEnvVars": ["MAGENTO_BASE_URL", "MAGENTO_ACCESS_TOKEN"], + "connector": { + "name": "Magento 2 REST", + "type": "REST", + "baseUrl": "{{MAGENTO_BASE_URL}}/rest/default/V1", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{MAGENTO_ACCESS_TOKEN}}" + } + }, + "tools": [ + { + "name": "magento_search_products", + "description": "Search products with Magento's search criteria DSL. For simple filters use the flat params (sku/name/status/visibility). For complex multi-condition use searchCriteria_raw.", + "parameters": { + "type": "object", + "properties": { + "sku_in": { "type": "string", "description": "Comma-separated SKUs (matches with condition_type=in)." }, + "name_like": { "type": "string", "description": "Name LIKE substring (auto-wraps in %)." }, + "status": { "type": "integer", "description": "1=enabled, 2=disabled." }, + "visibility": { "type": "integer", "description": "1=not visible, 2=catalog only, 3=search only, 4=catalog+search." }, + "pageSize": { "type": "integer", "description": "Per page (default 20, max ~300 due to URL length)." }, + "currentPage": { "type": "integer", "description": "1-based page." }, + "sortOrders_field": { "type": "string", "description": "Sort field (e.g. 'created_at', 'price')." }, + "sortOrders_direction": { "type": "string", "description": "ASC or DESC." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/products", + "queryParams": { + "searchCriteria[filter_groups][0][filters][0][field]": "sku", + "searchCriteria[filter_groups][0][filters][0][value]": "$sku_in", + "searchCriteria[filter_groups][0][filters][0][condition_type]": "in", + "searchCriteria[filter_groups][1][filters][0][field]": "name", + "searchCriteria[filter_groups][1][filters][0][value]": "$name_like", + "searchCriteria[filter_groups][1][filters][0][condition_type]": "like", + "searchCriteria[filter_groups][2][filters][0][field]": "status", + "searchCriteria[filter_groups][2][filters][0][value]": "$status", + "searchCriteria[filter_groups][2][filters][0][condition_type]": "eq", + "searchCriteria[filter_groups][3][filters][0][field]": "visibility", + "searchCriteria[filter_groups][3][filters][0][value]": "$visibility", + "searchCriteria[filter_groups][3][filters][0][condition_type]": "eq", + "searchCriteria[pageSize]": "$pageSize", + "searchCriteria[currentPage]": "$currentPage", + "searchCriteria[sortOrders][0][field]": "$sortOrders_field", + "searchCriteria[sortOrders][0][direction]": "$sortOrders_direction" + } + } + }, + { + "name": "magento_get_product", + "description": "Fetch a single product by SKU. Returns full attributes including custom_attributes[].", + "parameters": { + "type": "object", + "properties": { + "sku": { "type": "string", "description": "Product SKU (URL-encode if contains special chars)." } + }, + "required": ["sku"] + }, + "endpointMapping": { "method": "GET", "path": "/products/{sku}" } + }, + { + "name": "magento_create_product", + "description": "Create a product. Required: product.sku + product.name + product.type_id + product.attribute_set_id + product.price.", + "parameters": { + "type": "object", + "properties": { + "product": { + "type": "object", + "description": "{sku, name, type_id:'simple'|'configurable'|'virtual'|'bundle'|'grouped'|'downloadable', attribute_set_id:N (usually 4 for default), price, status?:1, visibility?:4, weight?, custom_attributes?:[{attribute_code,value}], extension_attributes?:{stock_item:{qty,is_in_stock}}}." + }, + "saveOptions": { "type": "boolean", "description": "If true, save configurable options too." } + }, + "required": ["product"] + }, + "endpointMapping": { + "method": "POST", + "path": "/products", + "bodyMapping": { + "product": "$product", + "saveOptions": "$saveOptions" + } + } + }, + { + "name": "magento_update_product", + "description": "Update a product by SKU (PUT).", + "parameters": { + "type": "object", + "properties": { + "sku": { "type": "string", "description": "Product SKU." }, + "product": { "type": "object", "description": "Partial product object — only fields to change." } + }, + "required": ["sku", "product"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/products/{sku}", + "bodyMapping": { "product": "$product" } + } + }, + { + "name": "magento_delete_product", + "description": "Permanently delete a product by SKU.", + "parameters": { + "type": "object", + "properties": { + "sku": { "type": "string", "description": "SKU to delete." } + }, + "required": ["sku"] + }, + "endpointMapping": { "method": "DELETE", "path": "/products/{sku}" } + }, + { + "name": "magento_update_stock", + "description": "Update inventory stock for a SKU at a source. For single-source (default 'default' source).", + "parameters": { + "type": "object", + "properties": { + "sku": { "type": "string", "description": "SKU." }, + "stockItem": { + "type": "object", + "description": "{qty:N, is_in_stock:bool, manage_stock?:bool, min_qty?, max_sale_qty?}." + } + }, + "required": ["sku", "stockItem"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/products/{sku}/stockItems/1", + "bodyMapping": { "stockItem": "$stockItem" } + } + }, + { + "name": "magento_list_categories", + "description": "List the category tree (or below a rootCategoryId).", + "parameters": { + "type": "object", + "properties": { + "rootCategoryId": { "type": "integer", "description": "Root category ID. Default 1 (root)." }, + "depth": { "type": "integer", "description": "Depth of tree to return." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/categories", + "queryParams": { + "rootCategoryId": "$rootCategoryId", + "depth": "$depth" + } + } + }, + { + "name": "magento_search_orders", + "description": "Search orders.", + "parameters": { + "type": "object", + "properties": { + "status": { "type": "string", "description": "pending, processing, complete, closed, canceled, holded." }, + "customer_email": { "type": "string", "description": "Filter by customer email." }, + "created_at_from": { "type": "string", "description": "ISO 8601 — orders created after." }, + "created_at_to": { "type": "string", "description": "ISO 8601 — created before." }, + "pageSize": { "type": "integer", "description": "Per page." }, + "currentPage": { "type": "integer", "description": "1-based page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/orders", + "queryParams": { + "searchCriteria[filter_groups][0][filters][0][field]": "status", + "searchCriteria[filter_groups][0][filters][0][value]": "$status", + "searchCriteria[filter_groups][0][filters][0][condition_type]": "eq", + "searchCriteria[filter_groups][1][filters][0][field]": "customer_email", + "searchCriteria[filter_groups][1][filters][0][value]": "$customer_email", + "searchCriteria[filter_groups][1][filters][0][condition_type]": "eq", + "searchCriteria[filter_groups][2][filters][0][field]": "created_at", + "searchCriteria[filter_groups][2][filters][0][value]": "$created_at_from", + "searchCriteria[filter_groups][2][filters][0][condition_type]": "gteq", + "searchCriteria[filter_groups][3][filters][0][field]": "created_at", + "searchCriteria[filter_groups][3][filters][0][value]": "$created_at_to", + "searchCriteria[filter_groups][3][filters][0][condition_type]": "lteq", + "searchCriteria[pageSize]": "$pageSize", + "searchCriteria[currentPage]": "$currentPage" + } + } + }, + { + "name": "magento_get_order", + "description": "Fetch one order by ID with billing/shipping addresses, items[], status history.", + "parameters": { + "type": "object", + "properties": { + "orderId": { "type": "integer", "description": "Order ID (entity_id)." } + }, + "required": ["orderId"] + }, + "endpointMapping": { "method": "GET", "path": "/orders/{orderId}" } + }, + { + "name": "magento_cancel_order", + "description": "Cancel an order (only valid in pending/processing status). Returns true on success.", + "parameters": { + "type": "object", + "properties": { + "orderId": { "type": "integer", "description": "Order ID." } + }, + "required": ["orderId"] + }, + "endpointMapping": { "method": "POST", "path": "/orders/{orderId}/cancel" } + }, + { + "name": "magento_search_customers", + "description": "Search customers with the same DSL.", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "Filter by email." }, + "firstname_like": { "type": "string", "description": "First name LIKE." }, + "lastname_like": { "type": "string", "description": "Last name LIKE." }, + "pageSize": { "type": "integer", "description": "Per page." }, + "currentPage": { "type": "integer", "description": "Page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/customers/search", + "queryParams": { + "searchCriteria[filter_groups][0][filters][0][field]": "email", + "searchCriteria[filter_groups][0][filters][0][value]": "$email", + "searchCriteria[filter_groups][0][filters][0][condition_type]": "eq", + "searchCriteria[filter_groups][1][filters][0][field]": "firstname", + "searchCriteria[filter_groups][1][filters][0][value]": "$firstname_like", + "searchCriteria[filter_groups][1][filters][0][condition_type]": "like", + "searchCriteria[filter_groups][2][filters][0][field]": "lastname", + "searchCriteria[filter_groups][2][filters][0][value]": "$lastname_like", + "searchCriteria[filter_groups][2][filters][0][condition_type]": "like", + "searchCriteria[pageSize]": "$pageSize", + "searchCriteria[currentPage]": "$currentPage" + } + } + }, + { + "name": "magento_get_customer", + "description": "Fetch one customer by ID.", + "parameters": { + "type": "object", + "properties": { + "customerId": { "type": "integer", "description": "Customer ID." } + }, + "required": ["customerId"] + }, + "endpointMapping": { "method": "GET", "path": "/customers/{customerId}" } + } + ] +} diff --git a/packages/backend/src/adapters/intl/magento.live.spec.ts b/packages/backend/src/adapters/intl/magento.live.spec.ts new file mode 100644 index 0000000..6dd8293 --- /dev/null +++ b/packages/backend/src/adapters/intl/magento.live.spec.ts @@ -0,0 +1,8 @@ +import * as adapter from './magento.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string; authConfig: Record } }; +describe('magento adapter — static spec conformance', () => { + it('per-store templated base URL ending /rest/default/V1', () => { + expect(a.connector.baseUrl).toBe('{{MAGENTO_BASE_URL}}/rest/default/V1'); + }); + it('Bearer auth (integration admin token)', () => expect(a.connector.authType).toBe('BEARER_TOKEN')); +}); diff --git a/packages/backend/src/adapters/intl/youtube-data.json b/packages/backend/src/adapters/intl/youtube-data.json new file mode 100644 index 0000000..8a1558b --- /dev/null +++ b/packages/backend/src/adapters/intl/youtube-data.json @@ -0,0 +1,256 @@ +{ + "slug": "youtube-data", + "name": "YouTube Data API", + "description": "Read YouTube data — videos, channels, playlists, search, comments — and (with OAuth) manage your own uploads. 9 tools, API key + optional OAuth Bearer.", + "instructions": "This connector uses the YouTube Data API v3 (developers.google.com/youtube/v3).\n\n**Setup**:\n1. Create / pick a Google Cloud project at https://console.cloud.google.com.\n2. **APIs & Services → Library → enable YouTube Data API v3**.\n3. **Credentials → Create credentials → API key**. (For write operations, also do OAuth2 client + get a Bearer token with `youtube.upload`/`youtube` scopes — out of scope here.)\n4. Set `YOUTUBE_API_KEY` to the API key.\n\n**Authentication**: query-string `?key=...`. Read operations don't need OAuth.\n\n**Quota model**: every API call costs **units** against your daily quota (default 10,000/day for new projects — request more via Google's quota form). search.list costs 100 units per call (most expensive), videos.list ~1 unit. Watch quota in the Cloud Console.\n\n**part parameter**: most resources let you choose which 'parts' of the object to return — e.g. `?part=snippet,statistics,contentDetails`. Slimmer = cheaper.\n\n**Pagination**: `nextPageToken` cursor.\n\n**Out of scope here**: video uploads (multipart + OAuth + resumable upload), captions, comment moderation, live broadcasts, monetization, channel branding.", + "region": "intl", + "category": "social", + "icon": "youtube", + "docsUrl": "https://developers.google.com/youtube/v3/docs", + "requiredEnvVars": ["YOUTUBE_API_KEY"], + "connector": { + "name": "YouTube Data v3", + "type": "REST", + "baseUrl": "https://www.googleapis.com/youtube/v3", + "authType": "QUERY_AUTH", + "authConfig": { + "key": "{{YOUTUBE_API_KEY}}" + } + }, + "tools": [ + { + "name": "youtube_search", + "description": "Search videos / channels / playlists. Costs 100 quota units. Filter by type, order, region, language, date range.", + "parameters": { + "type": "object", + "properties": { + "q": { "type": "string", "description": "Search query." }, + "type": { "type": "string", "description": "channel, playlist, video, or comma-separated." }, + "order": { "type": "string", "description": "date, rating, relevance (default), title, viewCount, videoCount." }, + "regionCode": { "type": "string", "description": "ISO 3166-1 alpha-2." }, + "relevanceLanguage": { "type": "string", "description": "ISO 639-1." }, + "publishedAfter": { "type": "string", "description": "ISO 8601 datetime." }, + "publishedBefore": { "type": "string", "description": "ISO 8601 datetime." }, + "channelId": { "type": "string", "description": "Restrict to videos by this channel." }, + "videoDuration": { "type": "string", "description": "any, short (<4min), medium (4-20min), long (>20min)." }, + "maxResults": { "type": "integer", "description": "1-50 (default 5)." }, + "pageToken": { "type": "string", "description": "Cursor for next page." }, + "part": { "type": "string", "description": "snippet (always for search). Other parts not available on search.list." } + }, + "required": ["q"] + }, + "endpointMapping": { + "method": "GET", + "path": "/search", + "queryParams": { + "q": "$q", + "type": "$type", + "order": "$order", + "regionCode": "$regionCode", + "relevanceLanguage": "$relevanceLanguage", + "publishedAfter": "$publishedAfter", + "publishedBefore": "$publishedBefore", + "channelId": "$channelId", + "videoDuration": "$videoDuration", + "maxResults": "$maxResults", + "pageToken": "$pageToken", + "part": "$part" + } + } + }, + { + "name": "youtube_get_videos", + "description": "Fetch video details by ID(s). Cheap (1 unit) — prefer this over search when you already have IDs.", + "parameters": { + "type": "object", + "properties": { + "id": { "type": "string", "description": "Comma-separated video IDs (max 50)." }, + "part": { "type": "string", "description": "Comma-separated: snippet, contentDetails, statistics, status, player, topicDetails, recordingDetails, fileDetails, liveStreamingDetails." } + }, + "required": ["id", "part"] + }, + "endpointMapping": { + "method": "GET", + "path": "/videos", + "queryParams": { + "id": "$id", + "part": "$part" + } + } + }, + { + "name": "youtube_get_videos_chart", + "description": "Get videos from a chart (currently only 'mostPopular'). Region-specific.", + "parameters": { + "type": "object", + "properties": { + "chart": { "type": "string", "description": "mostPopular." }, + "regionCode": { "type": "string", "description": "ISO 3166-1 alpha-2 country." }, + "videoCategoryId": { "type": "string", "description": "Restrict to a category." }, + "maxResults": { "type": "integer", "description": "1-50." }, + "part": { "type": "string", "description": "Parts." } + }, + "required": ["chart", "part"] + }, + "endpointMapping": { + "method": "GET", + "path": "/videos", + "queryParams": { + "chart": "$chart", + "regionCode": "$regionCode", + "videoCategoryId": "$videoCategoryId", + "maxResults": "$maxResults", + "part": "$part" + } + } + }, + { + "name": "youtube_get_channels", + "description": "Fetch channel details by ID, handle (@username), forUsername (legacy), or 'mine' (requires OAuth).", + "parameters": { + "type": "object", + "properties": { + "id": { "type": "string", "description": "Comma-separated channel IDs." }, + "forHandle": { "type": "string", "description": "Channel handle (with or without leading @)." }, + "forUsername": { "type": "string", "description": "Legacy username (mostly defunct)." }, + "mine": { "type": "boolean", "description": "true = the OAuth-authenticated user's channel (requires OAuth scope)." }, + "part": { "type": "string", "description": "snippet, contentDetails, statistics, status, brandingSettings, topicDetails, localizations." } + }, + "required": ["part"] + }, + "endpointMapping": { + "method": "GET", + "path": "/channels", + "queryParams": { + "id": "$id", + "forHandle": "$forHandle", + "forUsername": "$forUsername", + "mine": "$mine", + "part": "$part" + } + } + }, + { + "name": "youtube_list_playlists", + "description": "List playlists, filtered by channelId or 'mine' (OAuth).", + "parameters": { + "type": "object", + "properties": { + "channelId": { "type": "string", "description": "Channel ID." }, + "id": { "type": "string", "description": "Comma-separated playlist IDs." }, + "mine": { "type": "boolean", "description": "OAuth user's playlists." }, + "maxResults": { "type": "integer", "description": "1-50." }, + "pageToken": { "type": "string", "description": "Cursor." }, + "part": { "type": "string", "description": "snippet, contentDetails, player, status." } + }, + "required": ["part"] + }, + "endpointMapping": { + "method": "GET", + "path": "/playlists", + "queryParams": { + "channelId": "$channelId", + "id": "$id", + "mine": "$mine", + "maxResults": "$maxResults", + "pageToken": "$pageToken", + "part": "$part" + } + } + }, + { + "name": "youtube_list_playlist_items", + "description": "List items in a playlist (videos in playlist).", + "parameters": { + "type": "object", + "properties": { + "playlistId": { "type": "string", "description": "Playlist ID." }, + "videoId": { "type": "string", "description": "Filter to one specific video." }, + "maxResults": { "type": "integer", "description": "1-50." }, + "pageToken": { "type": "string", "description": "Cursor." }, + "part": { "type": "string", "description": "snippet, contentDetails, status, id." } + }, + "required": ["playlistId", "part"] + }, + "endpointMapping": { + "method": "GET", + "path": "/playlistItems", + "queryParams": { + "playlistId": "$playlistId", + "videoId": "$videoId", + "maxResults": "$maxResults", + "pageToken": "$pageToken", + "part": "$part" + } + } + }, + { + "name": "youtube_list_video_comments", + "description": "List top-level comments on a video. Use commentThreads.list (not comments.list which is for child comments).", + "parameters": { + "type": "object", + "properties": { + "videoId": { "type": "string", "description": "Video ID." }, + "order": { "type": "string", "description": "time (default) or relevance." }, + "maxResults": { "type": "integer", "description": "1-100." }, + "pageToken": { "type": "string", "description": "Cursor." }, + "searchTerms": { "type": "string", "description": "Filter to comments containing this text." }, + "part": { "type": "string", "description": "snippet, replies, id." } + }, + "required": ["videoId", "part"] + }, + "endpointMapping": { + "method": "GET", + "path": "/commentThreads", + "queryParams": { + "videoId": "$videoId", + "order": "$order", + "maxResults": "$maxResults", + "pageToken": "$pageToken", + "searchTerms": "$searchTerms", + "part": "$part" + } + } + }, + { + "name": "youtube_list_categories", + "description": "List video categories for a region.", + "parameters": { + "type": "object", + "properties": { + "regionCode": { "type": "string", "description": "ISO 3166-1 alpha-2." }, + "part": { "type": "string", "description": "snippet." } + }, + "required": ["part"] + }, + "endpointMapping": { + "method": "GET", + "path": "/videoCategories", + "queryParams": { + "regionCode": "$regionCode", + "part": "$part" + } + } + }, + { + "name": "youtube_get_captions", + "description": "List caption tracks available for a video.", + "parameters": { + "type": "object", + "properties": { + "videoId": { "type": "string", "description": "Video ID." }, + "part": { "type": "string", "description": "snippet, id." } + }, + "required": ["videoId", "part"] + }, + "endpointMapping": { + "method": "GET", + "path": "/captions", + "queryParams": { + "videoId": "$videoId", + "part": "$part" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/youtube-data.live.spec.ts b/packages/backend/src/adapters/intl/youtube-data.live.spec.ts new file mode 100644 index 0000000..ecf6111 --- /dev/null +++ b/packages/backend/src/adapters/intl/youtube-data.live.spec.ts @@ -0,0 +1,9 @@ +import * as adapter from './youtube-data.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string; authConfig: Record } }; +describe('youtube-data adapter — static spec conformance', () => { + it('googleapis.com/youtube/v3', () => expect(a.connector.baseUrl).toBe('https://www.googleapis.com/youtube/v3')); + it('QUERY_AUTH with key', () => { + expect(a.connector.authType).toBe('QUERY_AUTH'); + expect(a.connector.authConfig.key).toBe('{{YOUTUBE_API_KEY}}'); + }); +}); From e91e652620067e73474401f770c8ba13e836c924 Mon Sep 17 00:00:00 2001 From: Matteo Date: Wed, 20 May 2026 13:20:57 +0200 Subject: [PATCH 15/19] connectors: add Attio, Folk, TickTick, Slab, Dropbox Sign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 12 — modern CRM + project mgmt + knowledge + e-signature. - Attio v2: 12 tools — schema-flexible object model (people/ companies/deals/custom), query with attio filter DSL, get, create, update (PATCH), upsert via matching_attribute, lists, notes (markdown). Bearer. - Folk v2: 9 tools — people CRUD with group memberships, companies, groups with group-scoped custom fields. Bearer. - TickTick v1: 6 tools — projects (lists), get project with all tasks + columns, tasks CRUD with iCalendar RRULE recurrence, complete. OAuth2 Bearer. - Slab: 6 GraphQL tools — me, get post (markdown), search, topics, topic posts, create post from markdown. GRAPHQL connector type — auto-injected slab_graphql_query/mutation/ schema for arbitrary operations. - Dropbox Sign (formerly HelloSign) v3: 10 tools — signature requests CRUD (with test_mode=1 default), send-with-template vs send-from-file_urls, reminder, cancel, download final PDF, templates list+get. BASIC_AUTH with key as user. Catalog: 105 adapters (65/81 of the greenfield batch done, ~80%). --- packages/backend/src/adapters/catalog.ts | 10 + packages/backend/src/adapters/intl/attio.json | 209 ++++++++++++++++ .../src/adapters/intl/attio.live.spec.ts | 6 + .../src/adapters/intl/dropbox-sign.json | 226 ++++++++++++++++++ .../adapters/intl/dropbox-sign.live.spec.ts | 11 + packages/backend/src/adapters/intl/folk.json | 202 ++++++++++++++++ .../src/adapters/intl/folk.live.spec.ts | 6 + packages/backend/src/adapters/intl/slab.json | 136 +++++++++++ .../src/adapters/intl/slab.live.spec.ts | 9 + .../backend/src/adapters/intl/ticktick.json | 137 +++++++++++ .../src/adapters/intl/ticktick.live.spec.ts | 6 + 11 files changed, 958 insertions(+) create mode 100644 packages/backend/src/adapters/intl/attio.json create mode 100644 packages/backend/src/adapters/intl/attio.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/dropbox-sign.json create mode 100644 packages/backend/src/adapters/intl/dropbox-sign.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/folk.json create mode 100644 packages/backend/src/adapters/intl/folk.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/slab.json create mode 100644 packages/backend/src/adapters/intl/slab.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/ticktick.json create mode 100644 packages/backend/src/adapters/intl/ticktick.live.spec.ts diff --git a/packages/backend/src/adapters/catalog.ts b/packages/backend/src/adapters/catalog.ts index c96fdc3..9c02289 100644 --- a/packages/backend/src/adapters/catalog.ts +++ b/packages/backend/src/adapters/catalog.ts @@ -35,6 +35,7 @@ import * as activecampaign from './intl/activecampaign.json'; import * as acuityScheduling from './intl/acuity-scheduling.json'; import * as adyen from './intl/adyen.json'; import * as apollo from './intl/apollo.json'; +import * as attio from './intl/attio.json'; import * as basecamp from './intl/basecamp.json'; import * as beehiiv from './intl/beehiiv.json'; import * as bigcommerce from './intl/bigcommerce.json'; @@ -50,9 +51,11 @@ import * as copper from './intl/copper.json'; import * as crisp from './intl/crisp.json'; import * as discordBot from './intl/discord-bot.json'; import * as drip from './intl/drip.json'; +import * as dropboxSign from './intl/dropbox-sign.json'; import * as etsy from './intl/etsy.json'; import * as fathom from './intl/fathom.json'; import * as fillout from './intl/fillout.json'; +import * as folk from './intl/folk.json'; import * as freshdesk from './intl/freshdesk.json'; import * as front from './intl/front.json'; import * as ghost from './intl/ghost.json'; @@ -79,6 +82,7 @@ import * as recurly from './intl/recurly.json'; import * as reddit from './intl/reddit.json'; import * as salesloft from './intl/salesloft.json'; import * as sendgrid from './intl/sendgrid.json'; +import * as slab from './intl/slab.json'; import * as snov from './intl/snov.json'; import * as sorare from './intl/sorare.json'; import * as statsig from './intl/statsig.json'; @@ -86,6 +90,7 @@ import * as substack from './intl/substack.json'; import * as surveymonkey from './intl/surveymonkey.json'; import * as tally from './intl/tally.json'; import * as telegramBot from './intl/telegram-bot.json'; +import * as ticktick from './intl/ticktick.json'; import * as todoist from './intl/todoist.json'; import * as trello from './intl/trello.json'; import * as typeform from './intl/typeform.json'; @@ -207,6 +212,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ acuityScheduling as unknown as AdapterDefinition, adyen as unknown as AdapterDefinition, apollo as unknown as AdapterDefinition, + attio as unknown as AdapterDefinition, basecamp as unknown as AdapterDefinition, beehiiv as unknown as AdapterDefinition, bigcommerce as unknown as AdapterDefinition, @@ -222,9 +228,11 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ crisp as unknown as AdapterDefinition, discordBot as unknown as AdapterDefinition, drip as unknown as AdapterDefinition, + dropboxSign as unknown as AdapterDefinition, etsy as unknown as AdapterDefinition, fathom as unknown as AdapterDefinition, fillout as unknown as AdapterDefinition, + folk as unknown as AdapterDefinition, freshdesk as unknown as AdapterDefinition, front as unknown as AdapterDefinition, ghost as unknown as AdapterDefinition, @@ -251,6 +259,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ reddit as unknown as AdapterDefinition, salesloft as unknown as AdapterDefinition, sendgrid as unknown as AdapterDefinition, + slab as unknown as AdapterDefinition, snov as unknown as AdapterDefinition, sorare as unknown as AdapterDefinition, statsig as unknown as AdapterDefinition, @@ -258,6 +267,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ surveymonkey as unknown as AdapterDefinition, tally as unknown as AdapterDefinition, telegramBot as unknown as AdapterDefinition, + ticktick as unknown as AdapterDefinition, todoist as unknown as AdapterDefinition, trello as unknown as AdapterDefinition, typeform as unknown as AdapterDefinition, diff --git a/packages/backend/src/adapters/intl/attio.json b/packages/backend/src/adapters/intl/attio.json new file mode 100644 index 0000000..e9bac5c --- /dev/null +++ b/packages/backend/src/adapters/intl/attio.json @@ -0,0 +1,209 @@ +{ + "slug": "attio", + "name": "Attio", + "description": "Drive Attio (modern relationship-graph CRM) from any AI agent: records (people, companies, deals, custom objects), lists, tasks, notes. 12 tools, Bearer auth.", + "instructions": "This connector uses the Attio REST API v2 (developers.attio.com).\n\n**Setup**:\n1. Sign in to Attio → top-right avatar → **API & access → Create an access token** (or set up an OAuth app).\n2. Pick scopes: at minimum `object_configuration:read`, `record_permission:read`, `record_permission:read-write`, `list_configuration:read`, `note:read-write`.\n3. Copy the token. Set `ATTIO_ACCESS_TOKEN`.\n\n**Authentication**: `Authorization: Bearer ${ATTIO_ACCESS_TOKEN}`.\n\n**Object model**: Attio is highly schema-flexible. Default objects are `people`, `companies`, `deals`, `users`, `workspaces`. You can add custom objects (object_slug). Each object has attributes (fields) — different per workspace.\n\n**Records have a path-style ID**: `{object: 'people', record_id: 'abc-123-uuid'}`. Use `attio_list_objects` to see what's available.\n\n**Attributes / Values**: writing values requires the right shape per attribute type (text, number, date, status, select, multi-select, email-address, phone-number, currency, location, personal-name, record-reference). Wrap values in arrays: `{values: {email_addresses: [{email_address:'a@b.com'}]}}`.\n\n**Lists**: views/collections of records. Different from CRMs that use 'segments' or 'tags'. Each list has entries (record references).\n\n**Pagination**: `limit + offset`. Default 100, max 1000.\n\n**Rate limits**: 100 req per 10 sec per token. On 429 back off.\n\n**Out of scope here**: object/attribute schema CRUD, workspaces management, webhook subscription management.", + "region": "intl", + "category": "crm", + "icon": "attio", + "docsUrl": "https://developers.attio.com/reference", + "requiredEnvVars": ["ATTIO_ACCESS_TOKEN"], + "connector": { + "name": "Attio v2", + "type": "REST", + "baseUrl": "https://api.attio.com/v2", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{ATTIO_ACCESS_TOKEN}}" + } + }, + "tools": [ + { + "name": "attio_self", + "description": "Return the token's identity (workspace_id, workspace_name, access_token id, permissions).", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/self" } + }, + { + "name": "attio_list_objects", + "description": "List all objects defined in the workspace (built-in + custom). Returns id, api_slug, singular_noun, plural_noun, created_at.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/objects" } + }, + { + "name": "attio_list_object_attributes", + "description": "List attributes (fields) on an object. Returns id, api_slug, title, type (text/number/date/status/select/email-address/...), is_required, is_unique.", + "parameters": { + "type": "object", + "properties": { + "objectSlug": { "type": "string", "description": "Object api slug (e.g. 'people', 'companies')." } + }, + "required": ["objectSlug"] + }, + "endpointMapping": { "method": "GET", "path": "/objects/{objectSlug}/attributes" } + }, + { + "name": "attio_query_records", + "description": "Query records on an object with filters + sorting.", + "parameters": { + "type": "object", + "properties": { + "objectSlug": { "type": "string", "description": "Object api slug." }, + "filter": { "type": "object", "description": "Attio filter object, e.g. {email_addresses:{email_address:{contains:'@acme.com'}}}." }, + "sorts": { "type": "array", "description": "[{attribute:'last_interaction', field:'interacted_at', direction:'desc'}]." }, + "limit": { "type": "integer", "description": "Per page (default 100, max 1000)." }, + "offset": { "type": "integer", "description": "Offset." } + }, + "required": ["objectSlug"] + }, + "endpointMapping": { + "method": "POST", + "path": "/objects/{objectSlug}/records/query", + "bodyMapping": { + "filter": "$filter", + "sorts": "$sorts", + "limit": "$limit", + "offset": "$offset" + } + } + }, + { + "name": "attio_get_record", + "description": "Fetch a single record by object + record_id.", + "parameters": { + "type": "object", + "properties": { + "objectSlug": { "type": "string", "description": "Object slug." }, + "recordId": { "type": "string", "description": "Record UUID." } + }, + "required": ["objectSlug", "recordId"] + }, + "endpointMapping": { "method": "GET", "path": "/objects/{objectSlug}/records/{recordId}" } + }, + { + "name": "attio_create_record", + "description": "Create a record. Values are nested under `values` and keyed by attribute api_slug. Most attributes accept ARRAYS even for single-value (Attio's design for multi-value support).", + "parameters": { + "type": "object", + "properties": { + "objectSlug": { "type": "string", "description": "Object slug." }, + "data": { + "type": "object", + "description": "{values: {api_slug: , ...}}. E.g. for a person: {name:[{first_name:'Jane',last_name:'Doe'}], email_addresses:[{email_address:'a@b.com'}]}." + } + }, + "required": ["objectSlug", "data"] + }, + "endpointMapping": { + "method": "POST", + "path": "/objects/{objectSlug}/records", + "bodyMapping": { "data": "$data" } + } + }, + { + "name": "attio_update_record", + "description": "Update a record (PATCH semantics — only updates passed values). Use append=true to add to multi-value, false to replace.", + "parameters": { + "type": "object", + "properties": { + "objectSlug": { "type": "string", "description": "Object slug." }, + "recordId": { "type": "string", "description": "Record UUID." }, + "data": { "type": "object", "description": "{values:{...}} — only attributes to update." } + }, + "required": ["objectSlug", "recordId", "data"] + }, + "endpointMapping": { + "method": "PATCH", + "path": "/objects/{objectSlug}/records/{recordId}", + "bodyMapping": { "data": "$data" } + } + }, + { + "name": "attio_assert_record", + "description": "Upsert by a unique attribute (e.g. email). Required: matching_attribute parameter to identify the unique attribute. Creates if missing, updates if exists.", + "parameters": { + "type": "object", + "properties": { + "objectSlug": { "type": "string", "description": "Object slug." }, + "data": { "type": "object", "description": "{values:{...}} as in create." }, + "matching_attribute": { "type": "string", "description": "Attribute api_slug used for matching (e.g. 'email_addresses')." } + }, + "required": ["objectSlug", "data", "matching_attribute"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/objects/{objectSlug}/records", + "queryParams": { "matching_attribute": "$matching_attribute" }, + "bodyMapping": { "data": "$data" } + } + }, + { + "name": "attio_list_lists", + "description": "List the workspace's lists (views/collections of records). Returns id, name, parent_object, workspace_access.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/lists" } + }, + { + "name": "attio_add_record_to_list", + "description": "Add a record (as a list entry) to a list. Required: listId + parent_record_id (the source record).", + "parameters": { + "type": "object", + "properties": { + "listId": { "type": "string", "description": "List ID." }, + "data": { + "type": "object", + "description": "{parent_object: 'people', parent_record_id: 'uuid', entry_values?: {stage?:[{value:'New'}], ...} — list-specific attributes}." + } + }, + "required": ["listId", "data"] + }, + "endpointMapping": { + "method": "POST", + "path": "/lists/{listId}/entries", + "bodyMapping": { "data": "$data" } + } + }, + { + "name": "attio_list_notes", + "description": "List notes on records.", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Per page." }, + "offset": { "type": "integer", "description": "Offset." }, + "parent_object": { "type": "string", "description": "Filter to notes on a specific object slug." }, + "parent_record_id": { "type": "string", "description": "Filter to notes on a specific record." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/notes", + "queryParams": { + "limit": "$limit", + "offset": "$offset", + "parent_object": "$parent_object", + "parent_record_id": "$parent_record_id" + } + } + }, + { + "name": "attio_create_note", + "description": "Add a note to a record. format='plaintext' (default) or 'markdown'.", + "parameters": { + "type": "object", + "properties": { + "data": { + "type": "object", + "description": "{parent_object: 'people', parent_record_id: 'uuid', title?: 'Meeting notes', format:'plaintext'|'markdown', content:'Discussed Q3...'}." + } + }, + "required": ["data"] + }, + "endpointMapping": { + "method": "POST", + "path": "/notes", + "bodyMapping": { "data": "$data" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/attio.live.spec.ts b/packages/backend/src/adapters/intl/attio.live.spec.ts new file mode 100644 index 0000000..1311756 --- /dev/null +++ b/packages/backend/src/adapters/intl/attio.live.spec.ts @@ -0,0 +1,6 @@ +import * as adapter from './attio.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string; authConfig: Record } }; +describe('attio adapter — static spec conformance', () => { + it('api.attio.com/v2', () => expect(a.connector.baseUrl).toBe('https://api.attio.com/v2')); + it('Bearer auth', () => expect(a.connector.authType).toBe('BEARER_TOKEN')); +}); diff --git a/packages/backend/src/adapters/intl/dropbox-sign.json b/packages/backend/src/adapters/intl/dropbox-sign.json new file mode 100644 index 0000000..c03e14b --- /dev/null +++ b/packages/backend/src/adapters/intl/dropbox-sign.json @@ -0,0 +1,226 @@ +{ + "slug": "dropbox-sign", + "name": "Dropbox Sign (HelloSign)", + "description": "Drive Dropbox Sign (e-signature, formerly HelloSign) from any AI agent: signature requests, templates, signature embeds, downloads. 10 tools, API-key Basic auth.", + "instructions": "This connector uses the Dropbox Sign API v3 (developers.hellosign.com).\n\n**Setup**:\n1. Sign in to https://app.hellosign.com → top-right avatar → **API → Test Mode + Live API → Generate API Key**.\n2. Use the test API key during development; switch to live when going to production.\n3. Set `DROPBOX_SIGN_API_KEY`.\n\n**Authentication**: HTTP Basic with username=API_KEY, password=empty.\n\n**test_mode**: every write endpoint accepts `test_mode=1` to NOT charge credits / NOT send real signature requests. Always test first.\n\n**Signature Request workflow**:\n 1. Create signature request from a template (`create_with_template`) OR from file URLs (`create`).\n 2. Recipients receive emails to sign.\n 3. Poll `get` to track status (status_code: awaiting_signature, signed, declined, expired).\n 4. Download the final PDF after status=signed.\n\n**Embedded signing**: alternative flow where you host the signature UI in your app (no Dropbox Sign emails sent). Requires `embedded_signing_enabled: true` in your account.\n\n**Pagination**: `?page=N&page_size=M` (default 20, max 100).\n\n**Out of scope here**: templates editing (UI), API app management, embedded request URL flows beyond basics, fax, signature accounts setup.", + "region": "intl", + "category": "e-signature", + "icon": "dropbox-sign", + "docsUrl": "https://developers.hellosign.com/api/reference/", + "requiredEnvVars": ["DROPBOX_SIGN_API_KEY"], + "connector": { + "name": "Dropbox Sign v3", + "type": "REST", + "baseUrl": "https://api.hellosign.com/v3", + "authType": "BASIC_AUTH", + "authConfig": { + "username": "{{DROPBOX_SIGN_API_KEY}}", + "password": "" + } + }, + "tools": [ + { + "name": "dropbox_sign_get_account", + "description": "Return account info: email, role, quotas, callback_url.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/account" } + }, + { + "name": "dropbox_sign_list_signature_requests", + "description": "List signature requests, optionally filtered by account/query.", + "parameters": { + "type": "object", + "properties": { + "account_id": { "type": "string", "description": "Filter by account." }, + "page": { "type": "integer", "description": "Page (1-based)." }, + "page_size": { "type": "integer", "description": "Per page (max 100)." }, + "query": { "type": "string", "description": "Full-text search query." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/signature_request/list", + "queryParams": { + "account_id": "$account_id", + "page": "$page", + "page_size": "$page_size", + "query": "$query" + } + } + }, + { + "name": "dropbox_sign_get_signature_request", + "description": "Fetch a signature request by ID with full signer state.", + "parameters": { + "type": "object", + "properties": { + "signature_request_id": { "type": "string", "description": "Signature request ID." } + }, + "required": ["signature_request_id"] + }, + "endpointMapping": { "method": "GET", "path": "/signature_request/{signature_request_id}" } + }, + { + "name": "dropbox_sign_create_signature_request_with_template", + "description": "Create a signature request from a template. Required: template_ids + signers (with role) + subject. Always set test_mode=1 for testing.", + "parameters": { + "type": "object", + "properties": { + "template_ids": { "type": "array", "description": "Array of template IDs." }, + "subject": { "type": "string", "description": "Email subject." }, + "message": { "type": "string", "description": "Email body." }, + "signers": { + "type": "array", + "description": "[{role:'Client', name:'Jane Doe', email_address:'jane@acme.com'}]. Role must match template role names." + }, + "ccs": { "type": "array", "description": "[{role:'Manager', email_address:'mgr@acme.com'}]." }, + "custom_fields": { "type": "array", "description": "[{name:'company', value:'ACME', editor:'role-name', required:true}]." }, + "test_mode": { "type": "integer", "description": "1 for test (no credits, no real emails). 0 for live." }, + "client_id": { "type": "string", "description": "If using embedded signing." }, + "signing_options": { "type": "object", "description": "{draw, type, upload, phone, default:'draw'}." }, + "metadata": { "type": "object", "description": "Free-form metadata, max 10 keys." } + }, + "required": ["template_ids", "signers"] + }, + "endpointMapping": { + "method": "POST", + "path": "/signature_request/send_with_template", + "bodyMapping": { + "template_ids": "$template_ids", + "subject": "$subject", + "message": "$message", + "signers": "$signers", + "ccs": "$ccs", + "custom_fields": "$custom_fields", + "test_mode": "$test_mode", + "client_id": "$client_id", + "signing_options": "$signing_options", + "metadata": "$metadata" + } + } + }, + { + "name": "dropbox_sign_send_signature_request", + "description": "Create a signature request from file URLs or uploaded files. Pass file_urls (publicly accessible PDF URLs) or file (multipart — NOT supported here, use file_urls).", + "parameters": { + "type": "object", + "properties": { + "title": { "type": "string", "description": "Internal title." }, + "subject": { "type": "string", "description": "Email subject." }, + "message": { "type": "string", "description": "Email body." }, + "signers": { "type": "array", "description": "[{name, email_address, order?}]." }, + "ccs": { "type": "array", "description": "[{email_address}]." }, + "file_urls": { "type": "array", "description": "Array of publicly-accessible PDF URLs." }, + "test_mode": { "type": "integer", "description": "1 for test." }, + "use_text_tags": { "type": "boolean", "description": "If true, parse [sig|...|...] text-tags in the PDF." }, + "hide_text_tags": { "type": "boolean", "description": "Hide text tags in final PDF." }, + "metadata": { "type": "object", "description": "Free metadata." } + }, + "required": ["signers", "file_urls"] + }, + "endpointMapping": { + "method": "POST", + "path": "/signature_request/send", + "bodyMapping": { + "title": "$title", + "subject": "$subject", + "message": "$message", + "signers": "$signers", + "ccs": "$ccs", + "file_urls": "$file_urls", + "test_mode": "$test_mode", + "use_text_tags": "$use_text_tags", + "hide_text_tags": "$hide_text_tags", + "metadata": "$metadata" + } + } + }, + { + "name": "dropbox_sign_send_reminder", + "description": "Send a reminder email to a pending signer.", + "parameters": { + "type": "object", + "properties": { + "signature_request_id": { "type": "string", "description": "Signature request ID." }, + "email_address": { "type": "string", "description": "Signer email to remind." }, + "name": { "type": "string", "description": "Signer name (if multiple signers share email)." } + }, + "required": ["signature_request_id", "email_address"] + }, + "endpointMapping": { + "method": "POST", + "path": "/signature_request/remind/{signature_request_id}", + "bodyMapping": { + "email_address": "$email_address", + "name": "$name" + } + } + }, + { + "name": "dropbox_sign_cancel_signature_request", + "description": "Cancel an incomplete signature request. Irreversible.", + "parameters": { + "type": "object", + "properties": { + "signature_request_id": { "type": "string", "description": "Signature request ID." } + }, + "required": ["signature_request_id"] + }, + "endpointMapping": { "method": "POST", "path": "/signature_request/cancel/{signature_request_id}" } + }, + { + "name": "dropbox_sign_download_files", + "description": "Download the final signed PDF (or zip of all docs). Returns binary.", + "parameters": { + "type": "object", + "properties": { + "signature_request_id": { "type": "string", "description": "Signature request ID." }, + "file_type": { "type": "string", "description": "pdf (default) or zip." }, + "get_url": { "type": "boolean", "description": "If true, return a temp URL instead of binary." } + }, + "required": ["signature_request_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/signature_request/files/{signature_request_id}", + "queryParams": { + "file_type": "$file_type", + "get_url": "$get_url" + } + } + }, + { + "name": "dropbox_sign_list_templates", + "description": "List templates available on the account.", + "parameters": { + "type": "object", + "properties": { + "page": { "type": "integer", "description": "Page." }, + "page_size": { "type": "integer", "description": "Per page (max 100)." }, + "query": { "type": "string", "description": "Search query." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/template/list", + "queryParams": { + "page": "$page", + "page_size": "$page_size", + "query": "$query" + } + } + }, + { + "name": "dropbox_sign_get_template", + "description": "Fetch a template's details — roles, custom fields, files.", + "parameters": { + "type": "object", + "properties": { + "template_id": { "type": "string", "description": "Template ID." } + }, + "required": ["template_id"] + }, + "endpointMapping": { "method": "GET", "path": "/template/{template_id}" } + } + ] +} diff --git a/packages/backend/src/adapters/intl/dropbox-sign.live.spec.ts b/packages/backend/src/adapters/intl/dropbox-sign.live.spec.ts new file mode 100644 index 0000000..d42be08 --- /dev/null +++ b/packages/backend/src/adapters/intl/dropbox-sign.live.spec.ts @@ -0,0 +1,11 @@ +import * as adapter from './dropbox-sign.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string; authConfig: Record } }; +describe('dropbox-sign adapter — static spec conformance', () => { + it('api.hellosign.com/v3 (legacy hellosign domain still authoritative)', () => + expect(a.connector.baseUrl).toBe('https://api.hellosign.com/v3')); + it('Basic auth with key as user, empty password', () => { + expect(a.connector.authType).toBe('BASIC_AUTH'); + expect(a.connector.authConfig.username).toBe('{{DROPBOX_SIGN_API_KEY}}'); + expect(a.connector.authConfig.password).toBe(''); + }); +}); diff --git a/packages/backend/src/adapters/intl/folk.json b/packages/backend/src/adapters/intl/folk.json new file mode 100644 index 0000000..d0eb87a --- /dev/null +++ b/packages/backend/src/adapters/intl/folk.json @@ -0,0 +1,202 @@ +{ + "slug": "folk", + "name": "Folk", + "description": "Drive Folk (modern relationship CRM) from any AI agent: people, companies, groups, deals, custom fields. 9 tools, Bearer auth.", + "instructions": "This connector uses the Folk REST API v2 (developer.folk.app).\n\n**Setup**:\n1. Sign in to https://app.folk.app → bottom-left avatar → **API & Integrations → API → Generate API token**.\n2. Set `FOLK_API_TOKEN`.\n3. Note your workspace's groups — Folk's groups are loose collections (use them like tags).\n\n**Authentication**: `Authorization: Bearer ${FOLK_API_TOKEN}`.\n\n**People / Companies / Deals**: standard CRM objects, plus the relationship-graph idea — every person is linked to one or more companies, plus a free-form network of contacts.\n\n**Groups**: flexible — Folk's primary segmentation. Add a person to multiple groups; each group has its own custom fields visible only when the person is in that group.\n\n**Pagination**: cursor — `nextPageToken`.\n\n**Out of scope here**: messages composer, sequences, automations, integrations management.", + "region": "intl", + "category": "crm", + "icon": "folk", + "docsUrl": "https://developer.folk.app/", + "requiredEnvVars": ["FOLK_API_TOKEN"], + "connector": { + "name": "Folk v2", + "type": "REST", + "baseUrl": "https://api.folk.app/v2", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{FOLK_API_TOKEN}}" + } + }, + "tools": [ + { + "name": "folk_me", + "description": "Return the user the token belongs to.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/users/me" } + }, + { + "name": "folk_list_people", + "description": "List people with cursor pagination.", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Per page (default 100)." }, + "cursor": { "type": "string", "description": "nextPageToken from prior response." }, + "groupId": { "type": "string", "description": "Filter to people in this group." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/people", + "queryParams": { + "limit": "$limit", + "cursor": "$cursor", + "groupId": "$groupId" + } + } + }, + { + "name": "folk_get_person", + "description": "Fetch a person by ID with full contact details + groups + companies.", + "parameters": { + "type": "object", + "properties": { + "personId": { "type": "string", "description": "Person ID." } + }, + "required": ["personId"] + }, + "endpointMapping": { "method": "GET", "path": "/people/{personId}" } + }, + { + "name": "folk_create_person", + "description": "Create a person. Required: fullName OR firstName OR lastName.", + "parameters": { + "type": "object", + "properties": { + "fullName": { "type": "string", "description": "Full name." }, + "firstName": { "type": "string", "description": "First name." }, + "lastName": { "type": "string", "description": "Last name." }, + "emails": { "type": "array", "description": "[{value:'a@b.com'}]." }, + "phones": { "type": "array", "description": "[{value:'+1...'}]." }, + "jobTitle": { "type": "string", "description": "Title." }, + "companies": { "type": "array", "description": "[{id?:'companyId', name?:'Acme'}] — link to existing or create-by-name." }, + "groupIds": { "type": "array", "description": "Array of group IDs to add this person to." }, + "urls": { "type": "array", "description": "[{value:'https://...'}]." }, + "addresses": { "type": "array", "description": "[{value:'free-text address'}]." }, + "customFieldValues": { "type": "object", "description": "Map of {custom_field_id: value}." } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/people", + "bodyMapping": { + "fullName": "$fullName", + "firstName": "$firstName", + "lastName": "$lastName", + "emails": "$emails", + "phones": "$phones", + "jobTitle": "$jobTitle", + "companies": "$companies", + "groupIds": "$groupIds", + "urls": "$urls", + "addresses": "$addresses", + "customFieldValues": "$customFieldValues" + } + } + }, + { + "name": "folk_update_person", + "description": "Update a person (PATCH).", + "parameters": { + "type": "object", + "properties": { + "personId": { "type": "string", "description": "Person ID." }, + "fullName": { "type": "string", "description": "New full name." }, + "emails": { "type": "array", "description": "Replace emails." }, + "phones": { "type": "array", "description": "Replace phones." }, + "jobTitle": { "type": "string", "description": "New title." }, + "companies": { "type": "array", "description": "Replace companies." }, + "groupIds": { "type": "array", "description": "Replace group memberships." }, + "customFieldValues": { "type": "object", "description": "Update custom fields." } + }, + "required": ["personId"] + }, + "endpointMapping": { + "method": "PATCH", + "path": "/people/{personId}", + "bodyMapping": { + "fullName": "$fullName", + "emails": "$emails", + "phones": "$phones", + "jobTitle": "$jobTitle", + "companies": "$companies", + "groupIds": "$groupIds", + "customFieldValues": "$customFieldValues" + } + } + }, + { + "name": "folk_list_companies", + "description": "List companies.", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Per page." }, + "cursor": { "type": "string", "description": "Cursor." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/companies", + "queryParams": { "limit": "$limit", "cursor": "$cursor" } + } + }, + { + "name": "folk_create_company", + "description": "Create a company.", + "parameters": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Company name." }, + "domains": { "type": "array", "description": "['acme.com']." }, + "addresses": { "type": "array", "description": "[{value:'...'}]." }, + "phones": { "type": "array", "description": "[{value:'...'}]." }, + "customFieldValues": { "type": "object", "description": "Custom fields." } + }, + "required": ["name"] + }, + "endpointMapping": { + "method": "POST", + "path": "/companies", + "bodyMapping": { + "name": "$name", + "domains": "$domains", + "addresses": "$addresses", + "phones": "$phones", + "customFieldValues": "$customFieldValues" + } + } + }, + { + "name": "folk_list_groups", + "description": "List groups (collections). Each group has id, name, type, peopleCount.", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Per page." }, + "cursor": { "type": "string", "description": "Cursor." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/groups", + "queryParams": { "limit": "$limit", "cursor": "$cursor" } + } + }, + { + "name": "folk_list_group_custom_fields", + "description": "List custom fields defined on a specific group. Folk fields are group-scoped.", + "parameters": { + "type": "object", + "properties": { + "groupId": { "type": "string", "description": "Group ID." } + }, + "required": ["groupId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/groups/{groupId}/customFields" + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/folk.live.spec.ts b/packages/backend/src/adapters/intl/folk.live.spec.ts new file mode 100644 index 0000000..aed893e --- /dev/null +++ b/packages/backend/src/adapters/intl/folk.live.spec.ts @@ -0,0 +1,6 @@ +import * as adapter from './folk.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string; authConfig: Record } }; +describe('folk adapter — static spec conformance', () => { + it('api.folk.app/v2', () => expect(a.connector.baseUrl).toBe('https://api.folk.app/v2')); + it('Bearer auth', () => expect(a.connector.authType).toBe('BEARER_TOKEN')); +}); diff --git a/packages/backend/src/adapters/intl/slab.json b/packages/backend/src/adapters/intl/slab.json new file mode 100644 index 0000000..cfa98fd --- /dev/null +++ b/packages/backend/src/adapters/intl/slab.json @@ -0,0 +1,136 @@ +{ + "slug": "slab", + "name": "Slab", + "description": "Drive Slab (team wiki) from any AI agent via its GraphQL API: posts, topics, users, search. 6 tools, Bearer auth.", + "instructions": "This connector uses the Slab GraphQL API (api.slab.com/v1/graphql).\n\n**Setup**:\n1. Sign in to Slab → top-right avatar → **Account Settings → Tokens → Create token**.\n2. Pick scopes: at minimum `read posts`, `read topics`, `read users`.\n3. Set `SLAB_API_TOKEN`.\n\n**Authentication**: `Authorization: Bearer ${SLAB_API_TOKEN}`.\n\n**GraphQL-only**: Slab has no REST. The adapter exposes a few curated mutations + queries as wrappers; for arbitrary queries use the auto-injected GraphQL builtins (each GRAPHQL adapter gets `slab_graphql_schema`, `slab_graphql_query`, `slab_graphql_mutation` automatically).\n\n**Post format**: Slab posts use a JSON content tree (Slate.js-like). Reading is straightforward; writing requires composing the AST OR using the markdown-import endpoint.\n\n**Topics = folders**: posts live in topics (hierarchical).\n\n**Out of scope here**: post-content editing via AST (use markdown import), integrations, billing.", + "region": "intl", + "category": "knowledge", + "icon": "slab", + "docsUrl": "https://help.slab.com/en/articles/3138084-slab-api", + "requiredEnvVars": ["SLAB_API_TOKEN"], + "connector": { + "name": "Slab GraphQL", + "type": "GRAPHQL", + "baseUrl": "https://api.slab.com/v1/graphql", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{SLAB_API_TOKEN}}" + } + }, + "tools": [ + { + "name": "slab_me", + "description": "Return the user the token belongs to (id, name, email, organization). Use the auto-injected slab_graphql_query for more complex selections.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { + "method": "POST", + "path": "", + "bodyMapping": { + "query": "{ me { id name email organization { id name } } }" + } + } + }, + { + "name": "slab_get_post", + "description": "Fetch a post by ID with title + content (in markdown) + topics + owner.", + "parameters": { + "type": "object", + "properties": { + "id": { "type": "string", "description": "Post ID." } + }, + "required": ["id"] + }, + "endpointMapping": { + "method": "POST", + "path": "", + "bodyMapping": { + "query": "query GetPost($id: ID!) { post(id: $id) { id title content(format: MARKDOWN) updatedAt owner { name } topics { id name } } }", + "variables": { "id": "$id" } + } + } + }, + { + "name": "slab_search_posts", + "description": "Search posts by query text.", + "parameters": { + "type": "object", + "properties": { + "query": { "type": "string", "description": "Search text." }, + "first": { "type": "integer", "description": "Max results." } + }, + "required": ["query"] + }, + "endpointMapping": { + "method": "POST", + "path": "", + "bodyMapping": { + "query": "query SearchPosts($q: String!, $first: Int) { search(query: $q, first: $first) { edges { node { ... on Post { id title updatedAt owner { name } } } } } }", + "variables": { "q": "$query", "first": "$first" } + } + } + }, + { + "name": "slab_list_topics", + "description": "List top-level topics in the organization.", + "parameters": { + "type": "object", + "properties": { + "first": { "type": "integer", "description": "Max topics." } + } + }, + "endpointMapping": { + "method": "POST", + "path": "", + "bodyMapping": { + "query": "query ListTopics($first: Int) { topics(first: $first) { edges { node { id name description parent { id name } } } } }", + "variables": { "first": "$first" } + } + } + }, + { + "name": "slab_get_topic_posts", + "description": "List posts within a topic.", + "parameters": { + "type": "object", + "properties": { + "topicId": { "type": "string", "description": "Topic ID." }, + "first": { "type": "integer", "description": "Max results." } + }, + "required": ["topicId"] + }, + "endpointMapping": { + "method": "POST", + "path": "", + "bodyMapping": { + "query": "query TopicPosts($id: ID!, $first: Int) { topic(id: $id) { id name posts(first: $first) { edges { node { id title updatedAt } } } } }", + "variables": { "id": "$topicId", "first": "$first" } + } + } + }, + { + "name": "slab_create_post", + "description": "Create a post from markdown content.", + "parameters": { + "type": "object", + "properties": { + "title": { "type": "string", "description": "Post title." }, + "content": { "type": "string", "description": "Markdown content." }, + "topicIds": { "type": "array", "description": "Topic IDs to place the post in." } + }, + "required": ["title", "content"] + }, + "endpointMapping": { + "method": "POST", + "path": "", + "bodyMapping": { + "query": "mutation CreatePost($title: String!, $content: String!, $topics: [ID!]) { postCreate(title: $title, content: $content, format: MARKDOWN, topicIds: $topics) { id title } }", + "variables": { + "title": "$title", + "content": "$content", + "topics": "$topicIds" + } + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/slab.live.spec.ts b/packages/backend/src/adapters/intl/slab.live.spec.ts new file mode 100644 index 0000000..d4d9e63 --- /dev/null +++ b/packages/backend/src/adapters/intl/slab.live.spec.ts @@ -0,0 +1,9 @@ +import * as adapter from './slab.json'; +const a = adapter as unknown as { connector: { baseUrl: string; type: string; authType: string } }; +describe('slab adapter — static spec conformance', () => { + it('GraphQL endpoint', () => { + expect(a.connector.type).toBe('GRAPHQL'); + expect(a.connector.baseUrl).toBe('https://api.slab.com/v1/graphql'); + }); + it('Bearer auth', () => expect(a.connector.authType).toBe('BEARER_TOKEN')); +}); diff --git a/packages/backend/src/adapters/intl/ticktick.json b/packages/backend/src/adapters/intl/ticktick.json new file mode 100644 index 0000000..3438a5d --- /dev/null +++ b/packages/backend/src/adapters/intl/ticktick.json @@ -0,0 +1,137 @@ +{ + "slug": "ticktick", + "name": "TickTick", + "description": "Drive TickTick (cross-platform task manager) from any AI agent: projects, tasks, completed-tasks reporting. 6 tools, OAuth2 Bearer auth.", + "instructions": "This connector uses the TickTick Open API v1 (developer.ticktick.com).\n\n**Setup**:\n1. Register an app at https://developer.ticktick.com → **Manage Apps → New App**.\n2. Note **Client ID** and **Client Secret**, and configure a redirect URL.\n3. Run OAuth2 authorization-code flow with scopes `tasks:read tasks:write`.\n4. Set `TICKTICK_ACCESS_TOKEN` to the obtained token (lifetime varies; refresh externally).\n\n**Authentication**: `Authorization: Bearer ${TICKTICK_ACCESS_TOKEN}`.\n\n**Project hierarchy**: Inbox + user-created projects (lists) → tasks → subtasks (items).\n\n**Date format**: ISO 8601 with timezone (e.g. `2026-03-15T15:30:00+0000`).\n\n**Priority**: 0 (none), 1 (low), 3 (medium), 5 (high).\n\n**Recurring tasks**: use `repeatFlag` with iCalendar RRULE syntax.\n\n**Out of scope here**: pomodoro, habits, calendar import/export, tags management (TickTick doesn't expose full tag CRUD).", + "region": "intl", + "category": "project-management", + "icon": "ticktick", + "docsUrl": "https://developer.ticktick.com/api", + "requiredEnvVars": ["TICKTICK_ACCESS_TOKEN"], + "connector": { + "name": "TickTick v1", + "type": "REST", + "baseUrl": "https://api.ticktick.com/open/v1", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{TICKTICK_ACCESS_TOKEN}}" + } + }, + "tools": [ + { + "name": "ticktick_get_user_projects", + "description": "List the user's projects (lists). Each project has id, name, color, sortOrder, closed, groupId, viewMode, permission, kind.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/project" } + }, + { + "name": "ticktick_get_project_with_data", + "description": "Get a project with its tasks and columns. Returns {project, tasks[], columns[]}.", + "parameters": { + "type": "object", + "properties": { + "projectId": { "type": "string", "description": "Project ID." } + }, + "required": ["projectId"] + }, + "endpointMapping": { "method": "GET", "path": "/project/{projectId}/data" } + }, + { + "name": "ticktick_get_task", + "description": "Get a single task by project + task ID.", + "parameters": { + "type": "object", + "properties": { + "projectId": { "type": "string", "description": "Project ID." }, + "taskId": { "type": "string", "description": "Task ID." } + }, + "required": ["projectId", "taskId"] + }, + "endpointMapping": { "method": "GET", "path": "/project/{projectId}/task/{taskId}" } + }, + { + "name": "ticktick_create_task", + "description": "Create a task. Required: title + projectId. Other common fields: content (description), startDate, dueDate, priority, items (subtasks).", + "parameters": { + "type": "object", + "properties": { + "title": { "type": "string", "description": "Task title." }, + "projectId": { "type": "string", "description": "Target project ID (use inbox project if free-floating)." }, + "content": { "type": "string", "description": "Description / content." }, + "desc": { "type": "string", "description": "Subtitle-style description." }, + "isAllDay": { "type": "boolean", "description": "If true, all-day task." }, + "startDate": { "type": "string", "description": "ISO 8601." }, + "dueDate": { "type": "string", "description": "ISO 8601." }, + "timeZone": { "type": "string", "description": "TZ name (e.g. 'America/New_York')." }, + "reminders": { "type": "array", "description": "iCalendar TRIGGER strings like 'TRIGGER:-PT0S'." }, + "repeatFlag": { "type": "string", "description": "RRULE for recurrence." }, + "priority": { "type": "integer", "description": "0=none, 1=low, 3=medium, 5=high." }, + "sortOrder": { "type": "integer", "description": "Display order." }, + "items": { "type": "array", "description": "Subtasks: [{title, status?:0|2 for not_done/done, completedTime?, sortOrder?}]." } + }, + "required": ["title", "projectId"] + }, + "endpointMapping": { + "method": "POST", + "path": "/task", + "bodyMapping": { + "title": "$title", + "projectId": "$projectId", + "content": "$content", + "desc": "$desc", + "isAllDay": "$isAllDay", + "startDate": "$startDate", + "dueDate": "$dueDate", + "timeZone": "$timeZone", + "reminders": "$reminders", + "repeatFlag": "$repeatFlag", + "priority": "$priority", + "sortOrder": "$sortOrder", + "items": "$items" + } + } + }, + { + "name": "ticktick_update_task", + "description": "Update a task. POST to /task/{id}. Pass full task object (TickTick replaces rather than patches).", + "parameters": { + "type": "object", + "properties": { + "taskId": { "type": "string", "description": "Task ID." }, + "title": { "type": "string", "description": "Title." }, + "projectId": { "type": "string", "description": "Required for update — current project." }, + "content": { "type": "string", "description": "Content." }, + "dueDate": { "type": "string", "description": "ISO 8601 due." }, + "priority": { "type": "integer", "description": "Priority." }, + "items": { "type": "array", "description": "Subtasks." } + }, + "required": ["taskId"] + }, + "endpointMapping": { + "method": "POST", + "path": "/task/{taskId}", + "bodyMapping": { + "title": "$title", + "projectId": "$projectId", + "content": "$content", + "dueDate": "$dueDate", + "priority": "$priority", + "items": "$items" + } + } + }, + { + "name": "ticktick_complete_task", + "description": "Mark a task complete.", + "parameters": { + "type": "object", + "properties": { + "projectId": { "type": "string", "description": "Project ID." }, + "taskId": { "type": "string", "description": "Task ID." } + }, + "required": ["projectId", "taskId"] + }, + "endpointMapping": { "method": "POST", "path": "/project/{projectId}/task/{taskId}/complete" } + } + ] +} diff --git a/packages/backend/src/adapters/intl/ticktick.live.spec.ts b/packages/backend/src/adapters/intl/ticktick.live.spec.ts new file mode 100644 index 0000000..216ca17 --- /dev/null +++ b/packages/backend/src/adapters/intl/ticktick.live.spec.ts @@ -0,0 +1,6 @@ +import * as adapter from './ticktick.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string } }; +describe('ticktick adapter — static spec conformance', () => { + it('api.ticktick.com/open/v1', () => expect(a.connector.baseUrl).toBe('https://api.ticktick.com/open/v1')); + it('Bearer auth', () => expect(a.connector.authType).toBe('BEARER_TOKEN')); +}); From cb349e84a23c758436891c81aea7ddcf34230d42 Mon Sep 17 00:00:00 2001 From: Matteo Date: Wed, 20 May 2026 13:25:22 +0200 Subject: [PATCH 16/19] connectors: add Medium, Height, SavvyCal, MessageBird, SignWell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 13 — publishing + project mgmt + scheduling + comms + e-sig. - Medium v1: 5 tools — me/publications/contributors (read), create post under user OR publication with html/markdown content. Note Medium API is write-mostly (no public read for posts since 2019). - Height: 9 tools — workspace, lists, tasks with JSON filters, search, CRUD, users, custom fields. api-key prefix (NOT Bearer). - SavvyCal v1: 6 tools — scheduling links, meetings, cancel. Bearer. - MessageBird v1: 8 tools — balance, SMS send/list/get, voice call with callFlow steps, phone lookup, verify create+check (OTP flow). AccessKey prefix (NOT Bearer). - SignWell v1: 8 tools — documents list/get, create from URLs OR template, reminders, cancel, download completed PDF, templates. Dual-header auth (X-Api-Key + X-Api-Application via extraHeaders). Catalog: 110 adapters (70/81 of the greenfield batch done, ~86%). --- packages/backend/src/adapters/catalog.ts | 10 + .../backend/src/adapters/intl/height.json | 154 ++++++++++++++ .../src/adapters/intl/height.live.spec.ts | 6 + .../backend/src/adapters/intl/medium.json | 118 +++++++++++ .../src/adapters/intl/medium.live.spec.ts | 6 + .../src/adapters/intl/messagebird.json | 195 +++++++++++++++++ .../adapters/intl/messagebird.live.spec.ts | 6 + .../backend/src/adapters/intl/savvycal.json | 110 ++++++++++ .../src/adapters/intl/savvycal.live.spec.ts | 6 + .../backend/src/adapters/intl/signwell.json | 200 ++++++++++++++++++ .../src/adapters/intl/signwell.live.spec.ts | 9 + 11 files changed, 820 insertions(+) create mode 100644 packages/backend/src/adapters/intl/height.json create mode 100644 packages/backend/src/adapters/intl/height.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/medium.json create mode 100644 packages/backend/src/adapters/intl/medium.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/messagebird.json create mode 100644 packages/backend/src/adapters/intl/messagebird.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/savvycal.json create mode 100644 packages/backend/src/adapters/intl/savvycal.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/signwell.json create mode 100644 packages/backend/src/adapters/intl/signwell.live.spec.ts diff --git a/packages/backend/src/adapters/catalog.ts b/packages/backend/src/adapters/catalog.ts index 9c02289..21a2ba5 100644 --- a/packages/backend/src/adapters/catalog.ts +++ b/packages/backend/src/adapters/catalog.ts @@ -61,6 +61,7 @@ import * as front from './intl/front.json'; import * as ghost from './intl/ghost.json'; import * as gitbook from './intl/gitbook.json'; import * as heap from './intl/heap.json'; +import * as height from './intl/height.json'; import * as helpScout from './intl/help-scout.json'; import * as hunter from './intl/hunter.json'; import * as insightly from './intl/insightly.json'; @@ -71,6 +72,8 @@ import * as loops from './intl/loops.json'; import * as magento from './intl/magento.json'; import * as mailchimp from './intl/mailchimp.json'; import * as mapbox from './intl/mapbox.json'; +import * as medium from './intl/medium.json'; +import * as messagebird from './intl/messagebird.json'; import * as mintlify from './intl/mintlify.json'; import * as mollie from './intl/mollie.json'; import * as neverbounce from './intl/neverbounce.json'; @@ -81,7 +84,9 @@ import * as pipedrive from './intl/pipedrive.json'; import * as recurly from './intl/recurly.json'; import * as reddit from './intl/reddit.json'; import * as salesloft from './intl/salesloft.json'; +import * as savvycal from './intl/savvycal.json'; import * as sendgrid from './intl/sendgrid.json'; +import * as signwell from './intl/signwell.json'; import * as slab from './intl/slab.json'; import * as snov from './intl/snov.json'; import * as sorare from './intl/sorare.json'; @@ -238,6 +243,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ ghost as unknown as AdapterDefinition, gitbook as unknown as AdapterDefinition, heap as unknown as AdapterDefinition, + height as unknown as AdapterDefinition, helpScout as unknown as AdapterDefinition, hunter as unknown as AdapterDefinition, insightly as unknown as AdapterDefinition, @@ -248,6 +254,8 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ magento as unknown as AdapterDefinition, mailchimp as unknown as AdapterDefinition, mapbox as unknown as AdapterDefinition, + medium as unknown as AdapterDefinition, + messagebird as unknown as AdapterDefinition, mintlify as unknown as AdapterDefinition, mollie as unknown as AdapterDefinition, neverbounce as unknown as AdapterDefinition, @@ -258,7 +266,9 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ recurly as unknown as AdapterDefinition, reddit as unknown as AdapterDefinition, salesloft as unknown as AdapterDefinition, + savvycal as unknown as AdapterDefinition, sendgrid as unknown as AdapterDefinition, + signwell as unknown as AdapterDefinition, slab as unknown as AdapterDefinition, snov as unknown as AdapterDefinition, sorare as unknown as AdapterDefinition, diff --git a/packages/backend/src/adapters/intl/height.json b/packages/backend/src/adapters/intl/height.json new file mode 100644 index 0000000..c405fd2 --- /dev/null +++ b/packages/backend/src/adapters/intl/height.json @@ -0,0 +1,154 @@ +{ + "slug": "height", + "name": "Height", + "description": "Drive Height (modern AI-native project management) from any AI agent: tasks, lists, fields, users, activities. 9 tools, API-key Bearer auth.", + "instructions": "This connector uses the Height API v1 (height.app/api/docs).\n\n**Setup**:\n1. Sign in to Height → top-right avatar → **Settings → API & webhooks → Generate API key**.\n2. Set `HEIGHT_API_KEY`.\n\n**Authentication**: `Authorization: api-key ${HEIGHT_API_KEY}` (literal `api-key ` prefix, NOT `Bearer`).\n\n**Tasks** are the primary object. Each task has status, name, description, assignees, dueDate, listIds[], fields (custom), parentTaskId (for subtasks).\n\n**Lists**: how tasks are organized (similar to ClickUp lists or Linear projects). A task can belong to multiple lists.\n\n**Custom fields**: defined at the workspace level. Reference via fields object {fieldId: value}.\n\n**Pagination**: cursor-based via `?cursor=...`.\n\n**Out of scope here**: chat, video huddles, integrations management.", + "region": "intl", + "category": "project-management", + "icon": "height", + "docsUrl": "https://height.app/api/docs", + "requiredEnvVars": ["HEIGHT_API_KEY"], + "connector": { + "name": "Height API", + "type": "REST", + "baseUrl": "https://api.height.app", + "authType": "API_KEY", + "authConfig": { + "headerName": "Authorization", + "apiKey": "api-key {{HEIGHT_API_KEY}}" + } + }, + "tools": [ + { + "name": "height_get_workspace", + "description": "Return workspace info: id, model, url, name.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/workspace" } + }, + { + "name": "height_list_lists", + "description": "List all lists in the workspace. Returns id, name, key, type (list/smartlist/section), appearance, archivedAt.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/lists" } + }, + { + "name": "height_list_tasks", + "description": "List tasks with a filter query. Filter examples: '{\"listIds\":{\"values\":[\"L-1\"]}}' or '{\"status\":{\"values\":[\"backlog\",\"started\"]}}'. URL-encoded.", + "parameters": { + "type": "object", + "properties": { + "filters": { "type": "string", "description": "URL-encoded JSON filter expression." }, + "order": { "type": "string", "description": "JSON-encoded sort, e.g. '[{\"field\":\"createdAt\",\"direction\":\"desc\"}]'." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/tasks", + "queryParams": { "filters": "$filters", "order": "$order" } + } + }, + { + "name": "height_search_tasks", + "description": "Full-text search tasks.", + "parameters": { + "type": "object", + "properties": { + "query": { "type": "string", "description": "Text query." } + }, + "required": ["query"] + }, + "endpointMapping": { + "method": "GET", + "path": "/tasks/search", + "queryParams": { "query": "$query" } + } + }, + { + "name": "height_get_task", + "description": "Fetch a single task by ID OR index (e.g. 'T-42').", + "parameters": { + "type": "object", + "properties": { + "taskIdOrIndex": { "type": "string", "description": "Task ID (UUID) or display index (e.g. 'T-42')." } + }, + "required": ["taskIdOrIndex"] + }, + "endpointMapping": { "method": "GET", "path": "/tasks/{taskIdOrIndex}" } + }, + { + "name": "height_create_task", + "description": "Create a task. Required: listIds + name.", + "parameters": { + "type": "object", + "properties": { + "listIds": { "type": "array", "description": "Array of list IDs the task belongs to (at least 1)." }, + "name": { "type": "string", "description": "Task title." }, + "description": { "type": "string", "description": "Description (rich text, Markdown supported)." }, + "assigneesIds": { "type": "array", "description": "Array of user IDs." }, + "status": { "type": "string", "description": "Status name (per workspace's status list: e.g. 'backlog', 'started', 'completed')." }, + "dueDate": { "type": "string", "description": "ISO 8601 date." }, + "parentTaskId": { "type": "string", "description": "If subtask, parent task ID." }, + "fields": { "type": "object", "description": "Custom fields: {fieldId: value, ...}." } + }, + "required": ["listIds", "name"] + }, + "endpointMapping": { + "method": "POST", + "path": "/tasks", + "bodyMapping": { + "listIds": "$listIds", + "name": "$name", + "description": "$description", + "assigneesIds": "$assigneesIds", + "status": "$status", + "dueDate": "$dueDate", + "parentTaskId": "$parentTaskId", + "fields": "$fields" + } + } + }, + { + "name": "height_update_task", + "description": "Update a task (PATCH).", + "parameters": { + "type": "object", + "properties": { + "taskId": { "type": "string", "description": "Task ID." }, + "name": { "type": "string", "description": "New name." }, + "description": { "type": "string", "description": "New description." }, + "status": { "type": "string", "description": "Change status." }, + "assigneesIds": { "type": "array", "description": "Replace assignees." }, + "listIds": { "type": "array", "description": "Replace list memberships." }, + "dueDate": { "type": "string", "description": "Change due date." }, + "fields": { "type": "object", "description": "Update custom fields." } + }, + "required": ["taskId"] + }, + "endpointMapping": { + "method": "PATCH", + "path": "/tasks/{taskId}", + "bodyMapping": { + "name": "$name", + "description": "$description", + "status": "$status", + "assigneesIds": "$assigneesIds", + "listIds": "$listIds", + "dueDate": "$dueDate", + "fields": "$fields" + } + } + }, + { + "name": "height_list_users", + "description": "List workspace users (members).", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/users" } + }, + { + "name": "height_list_fields", + "description": "List custom fields defined in the workspace.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/fields" } + } + ] +} diff --git a/packages/backend/src/adapters/intl/height.live.spec.ts b/packages/backend/src/adapters/intl/height.live.spec.ts new file mode 100644 index 0000000..e5e7935 --- /dev/null +++ b/packages/backend/src/adapters/intl/height.live.spec.ts @@ -0,0 +1,6 @@ +import * as adapter from './height.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string; authConfig: Record } }; +describe('height adapter — static spec conformance', () => { + it('api.height.app', () => expect(a.connector.baseUrl).toBe('https://api.height.app')); + it('api-key prefix (NOT Bearer)', () => expect(a.connector.authConfig.apiKey).toBe('api-key {{HEIGHT_API_KEY}}')); +}); diff --git a/packages/backend/src/adapters/intl/medium.json b/packages/backend/src/adapters/intl/medium.json new file mode 100644 index 0000000..eb25930 --- /dev/null +++ b/packages/backend/src/adapters/intl/medium.json @@ -0,0 +1,118 @@ +{ + "slug": "medium", + "name": "Medium", + "description": "Publish posts to Medium (your own profile or a publication) from any AI agent. 5 tools, integration-token Bearer auth.", + "instructions": "This connector uses the Medium API v1 (github.com/Medium/medium-api-docs).\n\n**Important — limited write API**: Medium's public API is **write-mostly** for the user that owns the integration token. There's no public read API for posts (Medium walked back most read endpoints in 2019). Use RSS or scraping for reads.\n\n**Setup**:\n1. Sign in to https://medium.com → top-right avatar → **Settings → Security and apps → Integration tokens → New integration token**.\n2. Name it ('AnythingMCP'). Copy the token.\n3. Set `MEDIUM_INTEGRATION_TOKEN`.\n\n**Authentication**: `Authorization: Bearer ${MEDIUM_INTEGRATION_TOKEN}`.\n\n**Publishing flow**: get your userId (`medium_me`) → create a post (`medium_create_post`) under your user OR a publication (`medium_create_post_under_publication`).\n\n**Content format**: 'html' or 'markdown'. publishStatus: 'public', 'draft', 'unlisted'.\n\n**Tags**: max 5 tags per post, each ≤25 chars.\n\n**Out of scope here**: read posts (no public API), comments, claps, notifications.", + "region": "intl", + "category": "publishing", + "icon": "medium", + "docsUrl": "https://github.com/Medium/medium-api-docs", + "requiredEnvVars": ["MEDIUM_INTEGRATION_TOKEN"], + "connector": { + "name": "Medium v1", + "type": "REST", + "baseUrl": "https://api.medium.com/v1", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{MEDIUM_INTEGRATION_TOKEN}}" + } + }, + "tools": [ + { + "name": "medium_me", + "description": "Return the authenticated user: id, username, name, url, imageUrl.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/me" } + }, + { + "name": "medium_list_user_publications", + "description": "List publications the user contributes to. Returns each publication's id, name, description, url, imageUrl.", + "parameters": { + "type": "object", + "properties": { + "userId": { "type": "string", "description": "User ID from medium_me." } + }, + "required": ["userId"] + }, + "endpointMapping": { "method": "GET", "path": "/users/{userId}/publications" } + }, + { + "name": "medium_get_publication_contributors", + "description": "List contributors of a publication.", + "parameters": { + "type": "object", + "properties": { + "publicationId": { "type": "string", "description": "Publication ID." } + }, + "required": ["publicationId"] + }, + "endpointMapping": { "method": "GET", "path": "/publications/{publicationId}/contributors" } + }, + { + "name": "medium_create_post", + "description": "Create a post under the user's own profile. Required: userId + title + contentFormat + content.", + "parameters": { + "type": "object", + "properties": { + "userId": { "type": "string", "description": "User ID." }, + "title": { "type": "string", "description": "Post title." }, + "contentFormat": { "type": "string", "description": "html or markdown." }, + "content": { "type": "string", "description": "Post body (HTML or Markdown)." }, + "canonicalUrl": { "type": "string", "description": "Canonical URL if cross-posting." }, + "tags": { "type": "array", "description": "Up to 5 tag strings, each ≤25 chars." }, + "publishStatus": { "type": "string", "description": "public (default), draft, unlisted." }, + "license": { "type": "string", "description": "all-rights-reserved (default), cc-40-by, cc-40-by-sa, cc-40-by-nd, cc-40-by-nc, cc-40-by-nc-nd, cc-40-by-nc-sa, cc-40-zero, public-domain." }, + "notifyFollowers": { "type": "boolean", "description": "Notify followers (default true for public posts)." } + }, + "required": ["userId", "title", "contentFormat", "content"] + }, + "endpointMapping": { + "method": "POST", + "path": "/users/{userId}/posts", + "bodyMapping": { + "title": "$title", + "contentFormat": "$contentFormat", + "content": "$content", + "canonicalUrl": "$canonicalUrl", + "tags": "$tags", + "publishStatus": "$publishStatus", + "license": "$license", + "notifyFollowers": "$notifyFollowers" + } + } + }, + { + "name": "medium_create_post_under_publication", + "description": "Create a post under a publication. Same fields as medium_create_post but pass publicationId instead of userId.", + "parameters": { + "type": "object", + "properties": { + "publicationId": { "type": "string", "description": "Publication ID." }, + "title": { "type": "string", "description": "Title." }, + "contentFormat": { "type": "string", "description": "html or markdown." }, + "content": { "type": "string", "description": "Body." }, + "canonicalUrl": { "type": "string", "description": "Canonical URL." }, + "tags": { "type": "array", "description": "Tags." }, + "publishStatus": { "type": "string", "description": "public, draft, unlisted." }, + "license": { "type": "string", "description": "License." }, + "notifyFollowers": { "type": "boolean", "description": "Notify followers." } + }, + "required": ["publicationId", "title", "contentFormat", "content"] + }, + "endpointMapping": { + "method": "POST", + "path": "/publications/{publicationId}/posts", + "bodyMapping": { + "title": "$title", + "contentFormat": "$contentFormat", + "content": "$content", + "canonicalUrl": "$canonicalUrl", + "tags": "$tags", + "publishStatus": "$publishStatus", + "license": "$license", + "notifyFollowers": "$notifyFollowers" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/medium.live.spec.ts b/packages/backend/src/adapters/intl/medium.live.spec.ts new file mode 100644 index 0000000..db04c53 --- /dev/null +++ b/packages/backend/src/adapters/intl/medium.live.spec.ts @@ -0,0 +1,6 @@ +import * as adapter from './medium.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string } }; +describe('medium adapter — static spec conformance', () => { + it('api.medium.com/v1', () => expect(a.connector.baseUrl).toBe('https://api.medium.com/v1')); + it('Bearer auth', () => expect(a.connector.authType).toBe('BEARER_TOKEN')); +}); diff --git a/packages/backend/src/adapters/intl/messagebird.json b/packages/backend/src/adapters/intl/messagebird.json new file mode 100644 index 0000000..ccca856 --- /dev/null +++ b/packages/backend/src/adapters/intl/messagebird.json @@ -0,0 +1,195 @@ +{ + "slug": "messagebird", + "name": "MessageBird (Bird)", + "description": "Drive MessageBird / Bird (multichannel CPaaS — SMS, voice, WhatsApp via Bird, RCS) from any AI agent. 8 tools, AccessKey header auth.", + "instructions": "This connector uses the MessageBird API v1 (developers.messagebird.com).\n\n**Note**: MessageBird rebranded to **Bird** in 2023 (bird.com). The API endpoints at rest.messagebird.com remain functional for existing customers as of 2025; new accounts may have different endpoint paths. Verify with your account dashboard.\n\n**Setup**:\n1. Sign in to https://dashboard.messagebird.com → **Developers → API access** → Live or Test → copy AccessKey.\n2. Set `MESSAGEBIRD_ACCESS_KEY`.\n\n**Authentication**: header `Authorization: AccessKey ${MESSAGEBIRD_ACCESS_KEY}` (literal 'AccessKey' prefix).\n\n**Recipients format**: international phone numbers WITHOUT the `+` sign. e.g. `31612345678` for the Netherlands.\n\n**Datacoding**: 'plain' (default GSM-7), 'unicode' (UCS-2, allows emoji but doubles cost), 'auto' (let MessageBird pick).\n\n**Originator**: your sender ID. Can be alphanumeric (max 11 chars, sender-name approach) or a phone number you own/lease.\n\n**Voice via API**: also possible (/calls endpoint) but call flows are complex — out of scope here for the MVP.\n\n**Rate limits**: ~100 SMS/sec on standard, much higher with dedicated routes. On 429 back off.", + "region": "intl", + "category": "messaging", + "icon": "messagebird", + "docsUrl": "https://developers.messagebird.com/api/", + "requiredEnvVars": ["MESSAGEBIRD_ACCESS_KEY"], + "connector": { + "name": "MessageBird v1", + "type": "REST", + "baseUrl": "https://rest.messagebird.com", + "authType": "API_KEY", + "authConfig": { + "headerName": "Authorization", + "apiKey": "AccessKey {{MESSAGEBIRD_ACCESS_KEY}}" + } + }, + "tools": [ + { + "name": "messagebird_get_balance", + "description": "Return account balance (payment) and currency.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/balance" } + }, + { + "name": "messagebird_send_sms", + "description": "Send an SMS. Required: originator + recipients + body.", + "parameters": { + "type": "object", + "properties": { + "originator": { "type": "string", "description": "Sender ID (≤11 alphanumeric chars) or your owned phone number." }, + "recipients": { "type": "array", "description": "Array of phones in international format WITHOUT '+'. e.g. ['31612345678']." }, + "body": { "type": "string", "description": "Message body. With plain datacoding ≤160 chars per part." }, + "type": { "type": "string", "description": "sms (default), binary, flash, premium." }, + "datacoding": { "type": "string", "description": "plain (default), unicode, auto." }, + "reference": { "type": "string", "description": "Free-text reference for delivery report correlation." }, + "scheduledDatetime": { "type": "string", "description": "ISO 8601 to schedule send up to 24h ahead." }, + "validity": { "type": "integer", "description": "Validity period in seconds (default 1 day)." } + }, + "required": ["originator", "recipients", "body"] + }, + "endpointMapping": { + "method": "POST", + "path": "/messages", + "bodyMapping": { + "originator": "$originator", + "recipients": "$recipients", + "body": "$body", + "type": "$type", + "datacoding": "$datacoding", + "reference": "$reference", + "scheduledDatetime": "$scheduledDatetime", + "validity": "$validity" + } + } + }, + { + "name": "messagebird_get_message", + "description": "Get an SMS by ID with delivery status per recipient.", + "parameters": { + "type": "object", + "properties": { + "messageId": { "type": "string", "description": "Message ID." } + }, + "required": ["messageId"] + }, + "endpointMapping": { "method": "GET", "path": "/messages/{messageId}" } + }, + { + "name": "messagebird_list_messages", + "description": "List SMS messages with filters.", + "parameters": { + "type": "object", + "properties": { + "originator": { "type": "string", "description": "Filter by sender." }, + "recipient": { "type": "string", "description": "Filter by recipient phone." }, + "from": { "type": "string", "description": "From datetime (ISO 8601)." }, + "until": { "type": "string", "description": "Until datetime." }, + "status": { "type": "string", "description": "scheduled, sent, buffered, delivered, expired, delivery_failed, sent_failed." }, + "limit": { "type": "integer", "description": "Per page (default 10, max 100)." }, + "offset": { "type": "integer", "description": "Offset." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/messages", + "queryParams": { + "originator": "$originator", + "recipient": "$recipient", + "from": "$from", + "until": "$until", + "status": "$status", + "limit": "$limit", + "offset": "$offset" + } + } + }, + { + "name": "messagebird_send_voice_call", + "description": "Start an outbound voice call. The call follows the steps in callFlow (XML or JSON). Required: source + destination + callFlow.", + "parameters": { + "type": "object", + "properties": { + "source": { "type": "string", "description": "Your number (international, no +). Must be MessageBird-issued or verified caller ID." }, + "destination": { "type": "string", "description": "Recipient phone (no +)." }, + "callFlow": { + "type": "object", + "description": "{steps: [{action:'say', options:{payload:'Hello', voice:'female', language:'en-US'}}, {action:'transfer', options:{destination:'31612345678'}}]}." + }, + "webhook": { "type": "object", "description": "{url, token} — for event callbacks." } + }, + "required": ["source", "destination", "callFlow"] + }, + "endpointMapping": { + "method": "POST", + "path": "/calls", + "bodyMapping": { + "source": "$source", + "destination": "$destination", + "callFlow": "$callFlow", + "webhook": "$webhook" + } + } + }, + { + "name": "messagebird_lookup_phone", + "description": "Look up phone number details: country, type (mobile/landline), formats. Useful for validation + carrier detection.", + "parameters": { + "type": "object", + "properties": { + "phoneNumber": { "type": "string", "description": "Phone number to look up (international, no +)." }, + "countryCode": { "type": "string", "description": "ISO country code if you have a local number that needs disambiguation." } + }, + "required": ["phoneNumber"] + }, + "endpointMapping": { + "method": "GET", + "path": "/lookup/{phoneNumber}", + "queryParams": { "countryCode": "$countryCode" } + } + }, + { + "name": "messagebird_verify_create", + "description": "Send a verification code (OTP) via SMS/voice. Default 6-digit code, 30 sec validity. Use messagebird_verify_check to validate.", + "parameters": { + "type": "object", + "properties": { + "recipient": { "type": "string", "description": "Phone (international, no +)." }, + "originator": { "type": "string", "description": "Sender ID." }, + "type": { "type": "string", "description": "sms (default), tts (voice)." }, + "template": { "type": "string", "description": "Message template. Default 'Your verification code is %token.'. Must include %token literal." }, + "timeout": { "type": "integer", "description": "Seconds until code expires (default 30, max 3600)." }, + "tokenLength": { "type": "integer", "description": "Code length 4-10 (default 6)." }, + "voice": { "type": "string", "description": "For tts: male, female." }, + "language": { "type": "string", "description": "For tts: en-us, en-gb, de-de, fr-fr, ..." } + }, + "required": ["recipient"] + }, + "endpointMapping": { + "method": "POST", + "path": "/verify", + "bodyMapping": { + "recipient": "$recipient", + "originator": "$originator", + "type": "$type", + "template": "$template", + "timeout": "$timeout", + "tokenLength": "$tokenLength", + "voice": "$voice", + "language": "$language" + } + } + }, + { + "name": "messagebird_verify_check", + "description": "Verify a token (OTP) against a previously-created verification ID.", + "parameters": { + "type": "object", + "properties": { + "id": { "type": "string", "description": "Verification ID from verify_create response." }, + "token": { "type": "string", "description": "Code the user entered." } + }, + "required": ["id", "token"] + }, + "endpointMapping": { + "method": "GET", + "path": "/verify/{id}", + "queryParams": { "token": "$token" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/messagebird.live.spec.ts b/packages/backend/src/adapters/intl/messagebird.live.spec.ts new file mode 100644 index 0000000..7b551ce --- /dev/null +++ b/packages/backend/src/adapters/intl/messagebird.live.spec.ts @@ -0,0 +1,6 @@ +import * as adapter from './messagebird.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string; authConfig: Record } }; +describe('messagebird adapter — static spec conformance', () => { + it('rest.messagebird.com', () => expect(a.connector.baseUrl).toBe('https://rest.messagebird.com')); + it('AccessKey prefix (NOT Bearer)', () => expect(a.connector.authConfig.apiKey).toBe('AccessKey {{MESSAGEBIRD_ACCESS_KEY}}')); +}); diff --git a/packages/backend/src/adapters/intl/savvycal.json b/packages/backend/src/adapters/intl/savvycal.json new file mode 100644 index 0000000..2892205 --- /dev/null +++ b/packages/backend/src/adapters/intl/savvycal.json @@ -0,0 +1,110 @@ +{ + "slug": "savvycal", + "name": "SavvyCal", + "description": "Drive SavvyCal (modern team scheduling) from any AI agent: scheduling links, meetings, availability. 6 tools, API-key Bearer auth.", + "instructions": "This connector uses the SavvyCal API v1 (developer.savvycal.com).\n\n**Setup**:\n1. Sign in to https://savvycal.com → top-right avatar → **Developer → API Keys → Create new key**.\n2. Set `SAVVYCAL_API_KEY`.\n\n**Authentication**: `Authorization: Bearer ${SAVVYCAL_API_KEY}`.\n\n**Resource model**:\n - **Link**: a scheduling link template (`/me/intro-call`).\n - **Booking**: a scheduled meeting via a link.\n - **Calendar**: connected calendar source (Google/Outlook).\n\n**Pagination**: cursor — response has `meta.next_cursor`.\n\n**Out of scope here**: workflows automation editing, payments via Stripe, team round-robin config (UI-only).", + "region": "intl", + "category": "scheduling", + "icon": "savvycal", + "docsUrl": "https://developer.savvycal.com/", + "requiredEnvVars": ["SAVVYCAL_API_KEY"], + "connector": { + "name": "SavvyCal v1", + "type": "REST", + "baseUrl": "https://api.savvycal.com/v1", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{SAVVYCAL_API_KEY}}" + } + }, + "tools": [ + { + "name": "savvycal_me", + "description": "Return the user the token belongs to.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/me" } + }, + { + "name": "savvycal_list_links", + "description": "List scheduling links. Each has id, slug, name, public, single_use, link_url, durations[], owner.", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Per page (max 100)." }, + "after": { "type": "string", "description": "Cursor." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/links", + "queryParams": { "limit": "$limit", "after": "$after" } + } + }, + { + "name": "savvycal_get_link", + "description": "Fetch a scheduling link by ID with full details.", + "parameters": { + "type": "object", + "properties": { + "linkId": { "type": "string", "description": "Link ID." } + }, + "required": ["linkId"] + }, + "endpointMapping": { "method": "GET", "path": "/links/{linkId}" } + }, + { + "name": "savvycal_list_meetings", + "description": "List meetings (bookings).", + "parameters": { + "type": "object", + "properties": { + "after": { "type": "string", "description": "Cursor." }, + "limit": { "type": "integer", "description": "Per page." }, + "from": { "type": "string", "description": "ISO 8601 — meetings starting after." }, + "to": { "type": "string", "description": "ISO 8601 — starting before." }, + "state": { "type": "string", "description": "scheduled, canceled." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/meetings", + "queryParams": { + "after": "$after", + "limit": "$limit", + "from": "$from", + "to": "$to", + "state": "$state" + } + } + }, + { + "name": "savvycal_get_meeting", + "description": "Fetch a meeting by ID with invitee details, cancellation URL, reschedule URL.", + "parameters": { + "type": "object", + "properties": { + "meetingId": { "type": "string", "description": "Meeting ID." } + }, + "required": ["meetingId"] + }, + "endpointMapping": { "method": "GET", "path": "/meetings/{meetingId}" } + }, + { + "name": "savvycal_cancel_meeting", + "description": "Cancel a scheduled meeting. The invitee receives a cancellation email.", + "parameters": { + "type": "object", + "properties": { + "meetingId": { "type": "string", "description": "Meeting ID." }, + "reason": { "type": "string", "description": "Cancellation reason (shown in email)." } + }, + "required": ["meetingId"] + }, + "endpointMapping": { + "method": "DELETE", + "path": "/meetings/{meetingId}", + "bodyMapping": { "reason": "$reason" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/savvycal.live.spec.ts b/packages/backend/src/adapters/intl/savvycal.live.spec.ts new file mode 100644 index 0000000..90498b1 --- /dev/null +++ b/packages/backend/src/adapters/intl/savvycal.live.spec.ts @@ -0,0 +1,6 @@ +import * as adapter from './savvycal.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string } }; +describe('savvycal adapter — static spec conformance', () => { + it('api.savvycal.com/v1', () => expect(a.connector.baseUrl).toBe('https://api.savvycal.com/v1')); + it('Bearer auth', () => expect(a.connector.authType).toBe('BEARER_TOKEN')); +}); diff --git a/packages/backend/src/adapters/intl/signwell.json b/packages/backend/src/adapters/intl/signwell.json new file mode 100644 index 0000000..8ba75ee --- /dev/null +++ b/packages/backend/src/adapters/intl/signwell.json @@ -0,0 +1,200 @@ +{ + "slug": "signwell", + "name": "SignWell", + "description": "Drive SignWell (developer-friendly e-signature) from any AI agent: documents, templates, signature requests, signers. 8 tools, API key + application ID auth.", + "instructions": "This connector uses the SignWell API v1 (developers.signwell.com).\n\n**Setup**:\n1. Sign in to https://app.signwell.com → top-right avatar → **API → Create application**.\n2. Note the **Application ID** and **API Key**.\n3. Set:\n - `SIGNWELL_API_KEY` = the API key\n - `SIGNWELL_APPLICATION_ID` = the application ID\n\n**Authentication**: header `X-Api-Key: ${SIGNWELL_API_KEY}` + `X-Api-Application: ${SIGNWELL_APPLICATION_ID}` (both required).\n\n**test_mode**: every write endpoint accepts `test_mode=true` to validate without sending real emails or burning credits.\n\n**Document workflow**: create document from URL/upload → status='Sent' → recipients sign → status='Completed' → download PDF.\n\n**Signers / Recipients**: each has id, name, email, message, status (waiting/seen/signed/declined). Decline/cancel is supported via update endpoints.\n\n**Pagination**: `?offset=N&limit=M` (max 100).\n\n**Out of scope here**: webhook subscription management, custom fields per template, API app management.", + "region": "intl", + "category": "e-signature", + "icon": "signwell", + "docsUrl": "https://developers.signwell.com/reference", + "requiredEnvVars": ["SIGNWELL_API_KEY", "SIGNWELL_APPLICATION_ID"], + "connector": { + "name": "SignWell v1", + "type": "REST", + "baseUrl": "https://www.signwell.com/api/v1", + "authType": "API_KEY", + "authConfig": { + "headerName": "X-Api-Key", + "apiKey": "{{SIGNWELL_API_KEY}}", + "extraHeaders": { + "X-Api-Application": "{{SIGNWELL_APPLICATION_ID}}" + } + } + }, + "tools": [ + { + "name": "signwell_list_documents", + "description": "List documents with filters.", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Per page (max 100)." }, + "offset": { "type": "integer", "description": "Offset." }, + "search": { "type": "string", "description": "Substring filter on document name." }, + "status": { "type": "string", "description": "Draft, Sent, Completed, Canceled, Declined." }, + "folder_id": { "type": "string", "description": "Filter by folder." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/documents/", + "queryParams": { + "limit": "$limit", + "offset": "$offset", + "search": "$search", + "status": "$status", + "folder_id": "$folder_id" + } + } + }, + { + "name": "signwell_get_document", + "description": "Fetch a document by ID with recipients + signing status.", + "parameters": { + "type": "object", + "properties": { + "documentId": { "type": "string", "description": "Document ID." } + }, + "required": ["documentId"] + }, + "endpointMapping": { "method": "GET", "path": "/documents/{documentId}/" } + }, + { + "name": "signwell_create_document_from_url", + "description": "Create a document from public file URLs and send for signature. Required: files + recipients. test_mode=true is safe.", + "parameters": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Document name." }, + "subject": { "type": "string", "description": "Email subject." }, + "message": { "type": "string", "description": "Email body." }, + "test_mode": { "type": "boolean", "description": "true = test (no real signature/email)." }, + "draft": { "type": "boolean", "description": "true = create as draft (don't send yet)." }, + "files": { "type": "array", "description": "[{file_url:'https://...pdf', name:'Contract.pdf'}]." }, + "recipients": { "type": "array", "description": "[{id:'r1', name, email, message?, signing_order?, sender:bool, deliver_email:bool}]. ids are arbitrary strings that you use to place fields." }, + "fields": { "type": "array", "description": "Optional: pre-placed signature/text/date fields [[{type:'signature', recipient_id:'r1', x:100, y:100, page:1, width:200, height:50}]]. Inner array per page." }, + "expires_in": { "type": "integer", "description": "Days until expiration." }, + "reminders": { "type": "boolean", "description": "Send automatic reminders." }, + "redirect_url": { "type": "string", "description": "URL signer sees after completing." }, + "metadata": { "type": "object", "description": "Free-form metadata (string K/V)." }, + "with_signature_page": { "type": "boolean", "description": "Append SignWell signature page (audit trail)." } + }, + "required": ["files", "recipients"] + }, + "endpointMapping": { + "method": "POST", + "path": "/documents/", + "bodyMapping": { + "name": "$name", + "subject": "$subject", + "message": "$message", + "test_mode": "$test_mode", + "draft": "$draft", + "files": "$files", + "recipients": "$recipients", + "fields": "$fields", + "expires_in": "$expires_in", + "reminders": "$reminders", + "redirect_url": "$redirect_url", + "metadata": "$metadata", + "with_signature_page": "$with_signature_page" + } + } + }, + { + "name": "signwell_create_document_from_template", + "description": "Create a document from a SignWell template + recipient mapping. Required: template_id + recipients.", + "parameters": { + "type": "object", + "properties": { + "template_id": { "type": "string", "description": "Template ID." }, + "name": { "type": "string", "description": "Document name." }, + "subject": { "type": "string", "description": "Email subject." }, + "message": { "type": "string", "description": "Email body." }, + "test_mode": { "type": "boolean", "description": "Test mode." }, + "draft": { "type": "boolean", "description": "Create as draft." }, + "recipients": { "type": "array", "description": "[{placeholder_name:'Client', name, email}]. placeholder_name matches a placeholder defined on the template." }, + "template_fields": { "type": "array", "description": "[{api_id:'company_name', value:'ACME'}] — pre-fill template variables." }, + "expires_in": { "type": "integer", "description": "Expiration days." }, + "reminders": { "type": "boolean", "description": "Reminders." }, + "redirect_url": { "type": "string", "description": "Post-sign redirect." }, + "metadata": { "type": "object", "description": "Metadata." } + }, + "required": ["template_id", "recipients"] + }, + "endpointMapping": { + "method": "POST", + "path": "/document_templates/documents/", + "bodyMapping": { + "template_id": "$template_id", + "name": "$name", + "subject": "$subject", + "message": "$message", + "test_mode": "$test_mode", + "draft": "$draft", + "recipients": "$recipients", + "template_fields": "$template_fields", + "expires_in": "$expires_in", + "reminders": "$reminders", + "redirect_url": "$redirect_url", + "metadata": "$metadata" + } + } + }, + { + "name": "signwell_send_reminder", + "description": "Send a reminder email to pending recipients on a document.", + "parameters": { + "type": "object", + "properties": { + "documentId": { "type": "string", "description": "Document ID." } + }, + "required": ["documentId"] + }, + "endpointMapping": { + "method": "POST", + "path": "/documents/{documentId}/remind/" + } + }, + { + "name": "signwell_cancel_document", + "description": "Cancel a document (no further signing). Irreversible.", + "parameters": { + "type": "object", + "properties": { + "documentId": { "type": "string", "description": "Document ID." } + }, + "required": ["documentId"] + }, + "endpointMapping": { "method": "DELETE", "path": "/documents/{documentId}/" } + }, + { + "name": "signwell_get_document_files", + "description": "Get URLs of the document's signed files. Returns presigned URLs valid for ~24h.", + "parameters": { + "type": "object", + "properties": { + "documentId": { "type": "string", "description": "Document ID." } + }, + "required": ["documentId"] + }, + "endpointMapping": { "method": "GET", "path": "/documents/{documentId}/completed_pdf/" } + }, + { + "name": "signwell_list_templates", + "description": "List templates available on the account.", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Per page." }, + "offset": { "type": "integer", "description": "Offset." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/document_templates/", + "queryParams": { "limit": "$limit", "offset": "$offset" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/signwell.live.spec.ts b/packages/backend/src/adapters/intl/signwell.live.spec.ts new file mode 100644 index 0000000..d9609b1 --- /dev/null +++ b/packages/backend/src/adapters/intl/signwell.live.spec.ts @@ -0,0 +1,9 @@ +import * as adapter from './signwell.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string; authConfig: any } }; +describe('signwell adapter — static spec conformance', () => { + it('signwell.com/api/v1', () => expect(a.connector.baseUrl).toBe('https://www.signwell.com/api/v1')); + it('X-Api-Key + X-Api-Application extraHeaders', () => { + expect(a.connector.authConfig.headerName).toBe('X-Api-Key'); + expect(a.connector.authConfig.extraHeaders['X-Api-Application']).toBe('{{SIGNWELL_APPLICATION_ID}}'); + }); +}); From bb43dc06f2e7470d22242f113ff9200d5e366976 Mon Sep 17 00:00:00 2001 From: Matteo Date: Wed, 20 May 2026 13:30:14 +0200 Subject: [PATCH 17/19] connectors: add Microsoft Bookings, Kustomer, Ecwid, Vercel Analytics, Instantly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 14 — scheduling + support + e-commerce + analytics + cold email. - Microsoft Bookings (via Graph v1.0): 8 tools — businesses, services, staff, appointments CRUD + cancel-with-message. Bearer OAuth2 against Microsoft Graph. - Kustomer v1: 10 tools — customers search/CRUD with multi-handle support, conversations search/get/update status/assign, internal notes, outbound messages (email/sms/chat). Subdomain-templated. - Ecwid v3: 10 tools — store profile, products search/CRUD, categories, orders search/get/update with payment+fulfillment status codes, customers search. Store-templated baseUrl. - Vercel Analytics: 4 tools — user, projects list, deployments list, Speed Insights aggregations. Bearer. - Instantly v2: 8 tools — campaigns list/get/analytics, leads list/add with dedupe options, update interest_status (hot/warm/ cold), lead lists, sending accounts. Catalog: 115 adapters (75/81 of the greenfield batch done, ~93%). --- packages/backend/src/adapters/catalog.ts | 10 + packages/backend/src/adapters/intl/ecwid.json | 256 ++++++++++++++++++ .../src/adapters/intl/ecwid.live.spec.ts | 8 + .../backend/src/adapters/intl/instantly.json | 198 ++++++++++++++ .../src/adapters/intl/instantly.live.spec.ts | 6 + .../backend/src/adapters/intl/kustomer.json | 228 ++++++++++++++++ .../src/adapters/intl/kustomer.live.spec.ts | 8 + .../src/adapters/intl/microsoft-bookings.json | 211 +++++++++++++++ .../intl/microsoft-bookings.live.spec.ts | 6 + .../src/adapters/intl/vercel-analytics.json | 103 +++++++ .../intl/vercel-analytics.live.spec.ts | 6 + 11 files changed, 1040 insertions(+) create mode 100644 packages/backend/src/adapters/intl/ecwid.json create mode 100644 packages/backend/src/adapters/intl/ecwid.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/instantly.json create mode 100644 packages/backend/src/adapters/intl/instantly.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/kustomer.json create mode 100644 packages/backend/src/adapters/intl/kustomer.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/microsoft-bookings.json create mode 100644 packages/backend/src/adapters/intl/microsoft-bookings.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/vercel-analytics.json create mode 100644 packages/backend/src/adapters/intl/vercel-analytics.live.spec.ts diff --git a/packages/backend/src/adapters/catalog.ts b/packages/backend/src/adapters/catalog.ts index 21a2ba5..b1eb223 100644 --- a/packages/backend/src/adapters/catalog.ts +++ b/packages/backend/src/adapters/catalog.ts @@ -52,6 +52,7 @@ import * as crisp from './intl/crisp.json'; import * as discordBot from './intl/discord-bot.json'; import * as drip from './intl/drip.json'; import * as dropboxSign from './intl/dropbox-sign.json'; +import * as ecwid from './intl/ecwid.json'; import * as etsy from './intl/etsy.json'; import * as fathom from './intl/fathom.json'; import * as fillout from './intl/fillout.json'; @@ -65,7 +66,9 @@ import * as height from './intl/height.json'; import * as helpScout from './intl/help-scout.json'; import * as hunter from './intl/hunter.json'; import * as insightly from './intl/insightly.json'; +import * as instantly from './intl/instantly.json'; import * as klaviyo from './intl/klaviyo.json'; +import * as kustomer from './intl/kustomer.json'; import * as lemlist from './intl/lemlist.json'; import * as lemonsqueezy from './intl/lemonsqueezy.json'; import * as loops from './intl/loops.json'; @@ -74,6 +77,7 @@ import * as mailchimp from './intl/mailchimp.json'; import * as mapbox from './intl/mapbox.json'; import * as medium from './intl/medium.json'; import * as messagebird from './intl/messagebird.json'; +import * as microsoftBookings from './intl/microsoft-bookings.json'; import * as mintlify from './intl/mintlify.json'; import * as mollie from './intl/mollie.json'; import * as neverbounce from './intl/neverbounce.json'; @@ -99,6 +103,7 @@ import * as ticktick from './intl/ticktick.json'; import * as todoist from './intl/todoist.json'; import * as trello from './intl/trello.json'; import * as typeform from './intl/typeform.json'; +import * as vercelAnalytics from './intl/vercel-analytics.json'; import * as whatsappBusiness from './intl/whatsapp-business.json'; import * as woocommerce from './intl/woocommerce.json'; import * as wordpress from './intl/wordpress.json'; @@ -234,6 +239,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ discordBot as unknown as AdapterDefinition, drip as unknown as AdapterDefinition, dropboxSign as unknown as AdapterDefinition, + ecwid as unknown as AdapterDefinition, etsy as unknown as AdapterDefinition, fathom as unknown as AdapterDefinition, fillout as unknown as AdapterDefinition, @@ -247,7 +253,9 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ helpScout as unknown as AdapterDefinition, hunter as unknown as AdapterDefinition, insightly as unknown as AdapterDefinition, + instantly as unknown as AdapterDefinition, klaviyo as unknown as AdapterDefinition, + kustomer as unknown as AdapterDefinition, lemlist as unknown as AdapterDefinition, lemonsqueezy as unknown as AdapterDefinition, loops as unknown as AdapterDefinition, @@ -256,6 +264,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ mapbox as unknown as AdapterDefinition, medium as unknown as AdapterDefinition, messagebird as unknown as AdapterDefinition, + microsoftBookings as unknown as AdapterDefinition, mintlify as unknown as AdapterDefinition, mollie as unknown as AdapterDefinition, neverbounce as unknown as AdapterDefinition, @@ -281,6 +290,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ todoist as unknown as AdapterDefinition, trello as unknown as AdapterDefinition, typeform as unknown as AdapterDefinition, + vercelAnalytics as unknown as AdapterDefinition, whatsappBusiness as unknown as AdapterDefinition, woocommerce as unknown as AdapterDefinition, wordpress as unknown as AdapterDefinition, diff --git a/packages/backend/src/adapters/intl/ecwid.json b/packages/backend/src/adapters/intl/ecwid.json new file mode 100644 index 0000000..3f8a4c7 --- /dev/null +++ b/packages/backend/src/adapters/intl/ecwid.json @@ -0,0 +1,256 @@ +{ + "slug": "ecwid", + "name": "Ecwid", + "description": "Drive Ecwid (Lightspeed-owned e-commerce platform) from any AI agent: products, categories, orders, customers. 10 tools, OAuth2 Bearer auth, store-scoped URL.", + "instructions": "This connector uses the Ecwid REST API v3 (api-docs.ecwid.com).\n\n**Setup**:\n1. Sign in to Ecwid → **My Profile → My Apps → Create App** (or use a personal Storefront API token from your Ecwid control panel).\n2. Note your **Store ID** (numeric) — visible in your Ecwid control panel URL.\n3. Set:\n - `ECWID_STORE_ID` = numeric store ID\n - `ECWID_TOKEN` = your access token (private personal token or OAuth2 access token)\n\n**Authentication**: `Authorization: Bearer ${ECWID_TOKEN}`.\n\n**Store-scoped URL**: `https://app.ecwid.com/api/v3/{{ECWID_STORE_ID}}`.\n\n**Product model**: products have id, sku, name, price, originalPrice, costPrice, weight, quantity, unlimited (track stock?), enabled, inStock, categoryIds[], options[], variants[].\n\n**Order statuses (paymentStatus)**: AWAITING_PAYMENT, PAID, CANCELLED, REFUNDED, PARTIALLY_REFUNDED, INCOMPLETE.\n**fulfillmentStatus**: AWAITING_PROCESSING, PROCESSING, SHIPPED, DELIVERED, WILL_NOT_DELIVER, RETURNED, READY_FOR_PICKUP, OUT_FOR_DELIVERY.\n\n**Pagination**: `?offset=N&limit=M` (max 100).\n\n**Out of scope here**: subscription billing, ApplePay/GooglePay setup, payment gateway management, themes/storefront customization.", + "region": "intl", + "category": "e-commerce", + "icon": "ecwid", + "docsUrl": "https://api-docs.ecwid.com/", + "requiredEnvVars": ["ECWID_STORE_ID", "ECWID_TOKEN"], + "connector": { + "name": "Ecwid v3", + "type": "REST", + "baseUrl": "https://app.ecwid.com/api/v3/{{ECWID_STORE_ID}}", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{ECWID_TOKEN}}" + } + }, + "tools": [ + { + "name": "ecwid_get_profile", + "description": "Get store profile (name, currency, formats, plan).", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/profile" } + }, + { + "name": "ecwid_search_products", + "description": "Search products with filters.", + "parameters": { + "type": "object", + "properties": { + "keyword": { "type": "string", "description": "Search keyword across name/SKU/description." }, + "category": { "type": "integer", "description": "Filter by category ID." }, + "enabled": { "type": "boolean", "description": "true = only enabled products." }, + "inStock": { "type": "boolean", "description": "true = only in-stock." }, + "priceFrom": { "type": "number", "description": "Min price." }, + "priceTo": { "type": "number", "description": "Max price." }, + "sortBy": { "type": "string", "description": "RELEVANCE, ADDED_TIME_DESC, NAME_ASC, PRICE_ASC, etc." }, + "offset": { "type": "integer", "description": "Offset." }, + "limit": { "type": "integer", "description": "Per page (max 100)." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/products", + "queryParams": { + "keyword": "$keyword", + "category": "$category", + "enabled": "$enabled", + "inStock": "$inStock", + "priceFrom": "$priceFrom", + "priceTo": "$priceTo", + "sortBy": "$sortBy", + "offset": "$offset", + "limit": "$limit" + } + } + }, + { + "name": "ecwid_get_product", + "description": "Get a product by ID.", + "parameters": { + "type": "object", + "properties": { + "productId": { "type": "integer", "description": "Product ID." } + }, + "required": ["productId"] + }, + "endpointMapping": { "method": "GET", "path": "/products/{productId}" } + }, + { + "name": "ecwid_create_product", + "description": "Create a product. Required: name + price.", + "parameters": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Product name." }, + "sku": { "type": "string", "description": "SKU (auto-generated if omitted)." }, + "description": { "type": "string", "description": "HTML description." }, + "price": { "type": "number", "description": "Price." }, + "originalPrice": { "type": "number", "description": "Compare-at price." }, + "costPrice": { "type": "number", "description": "Cost basis." }, + "weight": { "type": "number", "description": "Weight." }, + "quantity": { "type": "integer", "description": "Stock quantity." }, + "unlimited": { "type": "boolean", "description": "If true, don't track stock." }, + "enabled": { "type": "boolean", "description": "Visible on storefront." }, + "categoryIds": { "type": "array", "description": "Array of category IDs." }, + "tax": { "type": "object", "description": "{enabledManualTaxes:[ids]}." }, + "media": { "type": "object", "description": "{images:[{url, alt?}], videos:[{providerId, videoUrl}]}." } + }, + "required": ["name", "price"] + }, + "endpointMapping": { + "method": "POST", + "path": "/products", + "bodyMapping": { + "name": "$name", + "sku": "$sku", + "description": "$description", + "price": "$price", + "originalPrice": "$originalPrice", + "costPrice": "$costPrice", + "weight": "$weight", + "quantity": "$quantity", + "unlimited": "$unlimited", + "enabled": "$enabled", + "categoryIds": "$categoryIds", + "tax": "$tax", + "media": "$media" + } + } + }, + { + "name": "ecwid_update_product", + "description": "Update a product (PUT — full replacement of passed fields).", + "parameters": { + "type": "object", + "properties": { + "productId": { "type": "integer", "description": "Product ID." }, + "name": { "type": "string", "description": "New name." }, + "price": { "type": "number", "description": "New price." }, + "quantity": { "type": "integer", "description": "New stock." }, + "enabled": { "type": "boolean", "description": "Visibility." }, + "description": { "type": "string", "description": "New description." }, + "categoryIds": { "type": "array", "description": "Replace categories." } + }, + "required": ["productId"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/products/{productId}", + "bodyMapping": { + "name": "$name", + "price": "$price", + "quantity": "$quantity", + "enabled": "$enabled", + "description": "$description", + "categoryIds": "$categoryIds" + } + } + }, + { + "name": "ecwid_list_categories", + "description": "List categories.", + "parameters": { + "type": "object", + "properties": { + "parent": { "type": "integer", "description": "Parent category ID (default 0 = root)." }, + "offset": { "type": "integer", "description": "Offset." }, + "limit": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/categories", + "queryParams": { + "parent": "$parent", + "offset": "$offset", + "limit": "$limit" + } + } + }, + { + "name": "ecwid_search_orders", + "description": "Search orders.", + "parameters": { + "type": "object", + "properties": { + "keywords": { "type": "string", "description": "Full-text across customer name/email/order #." }, + "paymentStatus": { "type": "string", "description": "AWAITING_PAYMENT, PAID, CANCELLED, REFUNDED, PARTIALLY_REFUNDED, INCOMPLETE." }, + "fulfillmentStatus": { "type": "string", "description": "AWAITING_PROCESSING, PROCESSING, SHIPPED, DELIVERED, etc." }, + "customer": { "type": "string", "description": "Customer email." }, + "createdFrom": { "type": "string", "description": "ISO 8601 datetime." }, + "createdTo": { "type": "string", "description": "ISO 8601 datetime." }, + "offset": { "type": "integer", "description": "Offset." }, + "limit": { "type": "integer", "description": "Per page (max 100)." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/orders", + "queryParams": { + "keywords": "$keywords", + "paymentStatus": "$paymentStatus", + "fulfillmentStatus": "$fulfillmentStatus", + "customer": "$customer", + "createdFrom": "$createdFrom", + "createdTo": "$createdTo", + "offset": "$offset", + "limit": "$limit" + } + } + }, + { + "name": "ecwid_get_order", + "description": "Get an order by number.", + "parameters": { + "type": "object", + "properties": { + "orderNumber": { "type": "string", "description": "Order number (e.g. '12345')." } + }, + "required": ["orderNumber"] + }, + "endpointMapping": { "method": "GET", "path": "/orders/{orderNumber}" } + }, + { + "name": "ecwid_update_order", + "description": "Update order — common: change paymentStatus, fulfillmentStatus, set trackingNumber.", + "parameters": { + "type": "object", + "properties": { + "orderNumber": { "type": "string", "description": "Order number." }, + "paymentStatus": { "type": "string", "description": "New payment status." }, + "fulfillmentStatus": { "type": "string", "description": "New fulfillment status." }, + "trackingNumber": { "type": "string", "description": "Tracking number." }, + "shippingMethod": { "type": "string", "description": "Shipping method name." } + }, + "required": ["orderNumber"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/orders/{orderNumber}", + "bodyMapping": { + "paymentStatus": "$paymentStatus", + "fulfillmentStatus": "$fulfillmentStatus", + "trackingNumber": "$trackingNumber", + "shippingMethod": "$shippingMethod" + } + } + }, + { + "name": "ecwid_search_customers", + "description": "Search customers.", + "parameters": { + "type": "object", + "properties": { + "keyword": { "type": "string", "description": "Search across name/email." }, + "email": { "type": "string", "description": "Exact email." }, + "offset": { "type": "integer", "description": "Offset." }, + "limit": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/customers", + "queryParams": { + "keyword": "$keyword", + "email": "$email", + "offset": "$offset", + "limit": "$limit" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/ecwid.live.spec.ts b/packages/backend/src/adapters/intl/ecwid.live.spec.ts new file mode 100644 index 0000000..7d1c533 --- /dev/null +++ b/packages/backend/src/adapters/intl/ecwid.live.spec.ts @@ -0,0 +1,8 @@ +import * as adapter from './ecwid.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string } }; +describe('ecwid adapter — static spec conformance', () => { + it('store-templated base URL', () => { + expect(a.connector.baseUrl).toBe('https://app.ecwid.com/api/v3/{{ECWID_STORE_ID}}'); + }); + it('Bearer auth', () => expect(a.connector.authType).toBe('BEARER_TOKEN')); +}); diff --git a/packages/backend/src/adapters/intl/instantly.json b/packages/backend/src/adapters/intl/instantly.json new file mode 100644 index 0000000..3bfaab9 --- /dev/null +++ b/packages/backend/src/adapters/intl/instantly.json @@ -0,0 +1,198 @@ +{ + "slug": "instantly", + "name": "Instantly", + "description": "Drive Instantly (cold-email outreach platform) from any AI agent: leads, campaigns, lead lists, accounts (sending mailboxes). 8 tools, Bearer auth.", + "instructions": "This connector uses the Instantly v2 API (developer.instantly.ai).\n\n**Setup**:\n1. Sign in to https://app.instantly.ai → bottom-left avatar → **Settings → Integrations → API → Generate New API Key**.\n2. Set `INSTANTLY_API_KEY`.\n\n**Authentication**: `Authorization: Bearer ${INSTANTLY_API_KEY}`.\n\n**Lead state**: leads have status (Active, Paused, Completed, Unsubscribed, Bounced, Skipped, Replied). Adding a lead with `skip_if_in_workspace=true` deduplicates.\n\n**Campaigns**: sequence of email steps. Adding a lead to a campaign enrolls it in the sequence.\n\n**Accounts (sending mailboxes)**: each campaign sends from one or more connected mailboxes — managed in UI.\n\n**Pagination**: `?starting_after=lead_id` cursor.\n\n**Out of scope here**: campaign content editing (UI-only), unibox conversations API (separate), warmup management.", + "region": "intl", + "category": "crm", + "icon": "instantly", + "docsUrl": "https://developer.instantly.ai/", + "requiredEnvVars": ["INSTANTLY_API_KEY"], + "connector": { + "name": "Instantly v2", + "type": "REST", + "baseUrl": "https://api.instantly.ai/api/v2", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{INSTANTLY_API_KEY}}" + } + }, + "tools": [ + { + "name": "instantly_list_campaigns", + "description": "List campaigns. Each has id, name, status, daily_limit.", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Per page (default 100, max 100)." }, + "starting_after": { "type": "string", "description": "Cursor — campaign ID to start after." }, + "search": { "type": "string", "description": "Name substring." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/campaigns", + "queryParams": { + "limit": "$limit", + "starting_after": "$starting_after", + "search": "$search" + } + } + }, + { + "name": "instantly_get_campaign", + "description": "Get a campaign by ID with full settings.", + "parameters": { + "type": "object", + "properties": { + "campaignId": { "type": "string", "description": "Campaign ID." } + }, + "required": ["campaignId"] + }, + "endpointMapping": { "method": "GET", "path": "/campaigns/{campaignId}" } + }, + { + "name": "instantly_get_campaign_analytics", + "description": "Get campaign aggregated stats (sent, opened, replied, bounced, unsubscribed).", + "parameters": { + "type": "object", + "properties": { + "campaign_id": { "type": "string", "description": "Campaign ID." }, + "start_date": { "type": "string", "description": "ISO 8601." }, + "end_date": { "type": "string", "description": "ISO 8601." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/campaigns/analytics", + "queryParams": { + "campaign_id": "$campaign_id", + "start_date": "$start_date", + "end_date": "$end_date" + } + } + }, + { + "name": "instantly_list_leads", + "description": "List leads. Filter by campaign / status.", + "parameters": { + "type": "object", + "properties": { + "campaign_id": { "type": "string", "description": "Filter by campaign." }, + "limit": { "type": "integer", "description": "Per page (max 100)." }, + "starting_after": { "type": "string", "description": "Cursor." }, + "status": { "type": "integer", "description": "1=Active, 2=Paused, 3=Completed, -1=Unsubscribed, -2=Bounced, -3=Skipped, 9=Replied." }, + "email": { "type": "string", "description": "Exact email filter." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/leads/list", + "queryParams": { + "campaign_id": "$campaign_id", + "limit": "$limit", + "starting_after": "$starting_after", + "status": "$status", + "email": "$email" + } + } + }, + { + "name": "instantly_add_lead", + "description": "Add a lead to a campaign (or to a lead list if no campaign).", + "parameters": { + "type": "object", + "properties": { + "campaign": { "type": "string", "description": "Campaign ID to add to." }, + "list_id": { "type": "string", "description": "Lead list ID (alternative)." }, + "email": { "type": "string", "description": "Lead email." }, + "first_name": { "type": "string", "description": "First name." }, + "last_name": { "type": "string", "description": "Last name." }, + "company_name": { "type": "string", "description": "Company." }, + "phone": { "type": "string", "description": "Phone." }, + "website": { "type": "string", "description": "Website URL." }, + "personalization": { "type": "string", "description": "AI/manual personalization snippet for {{personalization}} template variable." }, + "custom_variables": { "type": "object", "description": "Map of custom variable names to values (used in {{var_name}} template tags)." }, + "skip_if_in_workspace": { "type": "boolean", "description": "Skip if email exists anywhere in workspace (dedupe)." }, + "skip_if_in_campaign": { "type": "boolean", "description": "Skip if already in THIS campaign." } + }, + "required": ["email"] + }, + "endpointMapping": { + "method": "POST", + "path": "/leads", + "bodyMapping": { + "campaign": "$campaign", + "list_id": "$list_id", + "email": "$email", + "first_name": "$first_name", + "last_name": "$last_name", + "company_name": "$company_name", + "phone": "$phone", + "website": "$website", + "personalization": "$personalization", + "custom_variables": "$custom_variables", + "skip_if_in_workspace": "$skip_if_in_workspace", + "skip_if_in_campaign": "$skip_if_in_campaign" + } + } + }, + { + "name": "instantly_update_lead_interest_status", + "description": "Update a lead's interest_status (a hot/warm/cold flag set during replies).", + "parameters": { + "type": "object", + "properties": { + "leadId": { "type": "string", "description": "Lead ID." }, + "interest_status": { "type": "integer", "description": "1=Interested, 2=Meeting Booked, 3=Meeting Completed, 4=Closed, 5=Not Interested, 6=Wrong Person, 7=Lost." } + }, + "required": ["leadId", "interest_status"] + }, + "endpointMapping": { + "method": "POST", + "path": "/leads/{leadId}", + "bodyMapping": { + "interest_status": "$interest_status" + } + } + }, + { + "name": "instantly_list_lead_lists", + "description": "List lead lists (standalone, not yet in a campaign).", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Per page." }, + "starting_after": { "type": "string", "description": "Cursor." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/lead-lists", + "queryParams": { + "limit": "$limit", + "starting_after": "$starting_after" + } + } + }, + { + "name": "instantly_list_accounts", + "description": "List sending accounts (mailboxes). Each has email, status, daily_limit, warmup status.", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Per page." }, + "starting_after": { "type": "string", "description": "Cursor." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/accounts", + "queryParams": { + "limit": "$limit", + "starting_after": "$starting_after" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/instantly.live.spec.ts b/packages/backend/src/adapters/intl/instantly.live.spec.ts new file mode 100644 index 0000000..7e01fc4 --- /dev/null +++ b/packages/backend/src/adapters/intl/instantly.live.spec.ts @@ -0,0 +1,6 @@ +import * as adapter from './instantly.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string } }; +describe('instantly adapter — static spec conformance', () => { + it('api.instantly.ai/api/v2', () => expect(a.connector.baseUrl).toBe('https://api.instantly.ai/api/v2')); + it('Bearer auth', () => expect(a.connector.authType).toBe('BEARER_TOKEN')); +}); diff --git a/packages/backend/src/adapters/intl/kustomer.json b/packages/backend/src/adapters/intl/kustomer.json new file mode 100644 index 0000000..eea8ddf --- /dev/null +++ b/packages/backend/src/adapters/intl/kustomer.json @@ -0,0 +1,228 @@ +{ + "slug": "kustomer", + "name": "Kustomer", + "description": "Drive Kustomer (modern customer service CRM, Meta-owned) from any AI agent: conversations, messages, customers, notes, custom objects. 10 tools, Bearer auth.", + "instructions": "This connector uses the Kustomer API v1 (developer.kustomer.com).\n\n**Setup**:\n1. Sign in to Kustomer → top-right avatar → **Settings → Security → API Keys → Add API Key**.\n2. Pick role: org.permission.customer.read+write, org.permission.conversation.read+write, etc.\n3. Set `KUSTOMER_API_KEY`.\n4. Note your **org subdomain** (e.g. `acme` for `acme.kustomerapp.com`). Set `KUSTOMER_SUBDOMAIN`.\n\n**Authentication**: `Authorization: Bearer ${KUSTOMER_API_KEY}`.\n\n**Subdomain-templated URL**: `https://${KUSTOMER_SUBDOMAIN}.api.kustomerapp.com/v1`.\n\n**Customer-centric model**: Kustomer's selling point is the unified customer timeline. A customer has multiple emails/phones/social handles; conversations roll up under the customer.\n\n**JSON:API conventions**: requests/responses follow JSON:API. Use `?include=` to side-load.\n\n**Pagination**: `?page[size]=N&page[number]=M`.\n\n**Out of scope here**: workflows/automations editing, KSAT (CSAT survey) config, knowledge base.", + "region": "intl", + "category": "support", + "icon": "kustomer", + "docsUrl": "https://developer.kustomer.com/kustomer-api-docs/reference", + "requiredEnvVars": ["KUSTOMER_API_KEY", "KUSTOMER_SUBDOMAIN"], + "connector": { + "name": "Kustomer v1", + "type": "REST", + "baseUrl": "https://{{KUSTOMER_SUBDOMAIN}}.api.kustomerapp.com/v1", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{KUSTOMER_API_KEY}}" + } + }, + "tools": [ + { + "name": "kustomer_get_current_tracking_id", + "description": "Return the org / org details — sanity check for API key validity.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/orgs/current" } + }, + { + "name": "kustomer_search_customers", + "description": "Search customers via the v1 search endpoint (Kustomer's Lucene-style query).", + "parameters": { + "type": "object", + "properties": { + "queryQueries": { "type": "array", "description": "Array of {and:[{name:'fieldname',operator:'equals',value:'X'},...], or:[]}." }, + "queryContext": { "type": "string", "description": "customer (default), conversation, message." }, + "pageSize": { "type": "integer", "description": "Per page." }, + "pageNumber": { "type": "integer", "description": "1-based page." } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/customers/search", + "queryParams": { + "pageSize": "$pageSize", + "pageNumber": "$pageNumber" + }, + "bodyMapping": { + "queryQueries": "$queryQueries", + "queryContext": "$queryContext" + } + } + }, + { + "name": "kustomer_get_customer", + "description": "Fetch a customer by ID. Pass include=conversations,messages,notes to side-load timeline.", + "parameters": { + "type": "object", + "properties": { + "customerId": { "type": "string", "description": "Customer ID." }, + "include": { "type": "string", "description": "Comma-separated relationships." } + }, + "required": ["customerId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/customers/{customerId}", + "queryParams": { "include": "$include" } + } + }, + { + "name": "kustomer_create_customer", + "description": "Create a customer. Required: name OR emails (at least one identifier).", + "parameters": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Full name." }, + "displayName": { "type": "string", "description": "Display name override." }, + "emails": { "type": "array", "description": "[{email, type:'work'|'home'|'other', verified?:bool}]." }, + "phones": { "type": "array", "description": "[{phone:'+15551234567', type, verified?}]." }, + "socials": { "type": "array", "description": "[{type:'twitter'|'facebook'|..., identifier:'@handle'}]." }, + "tags": { "type": "array", "description": "Tag strings." }, + "locale": { "type": "string", "description": "User locale." }, + "timeZone": { "type": "string", "description": "TZ database name." }, + "custom": { "type": "object", "description": "Custom attributes (must be defined in org settings first)." } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/customers", + "bodyMapping": { + "name": "$name", + "displayName": "$displayName", + "emails": "$emails", + "phones": "$phones", + "socials": "$socials", + "tags": "$tags", + "locale": "$locale", + "timeZone": "$timeZone", + "custom": "$custom" + } + } + }, + { + "name": "kustomer_search_conversations", + "description": "Search conversations.", + "parameters": { + "type": "object", + "properties": { + "queryQueries": { "type": "array", "description": "Kustomer query array." }, + "pageSize": { "type": "integer", "description": "Per page." }, + "pageNumber": { "type": "integer", "description": "Page." } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/conversations/search", + "queryParams": { + "pageSize": "$pageSize", + "pageNumber": "$pageNumber" + }, + "bodyMapping": { + "queryQueries": "$queryQueries" + } + } + }, + { + "name": "kustomer_get_conversation", + "description": "Fetch a conversation by ID.", + "parameters": { + "type": "object", + "properties": { + "conversationId": { "type": "string", "description": "Conversation ID." }, + "include": { "type": "string", "description": "Side-load (messages, notes, customer)." } + }, + "required": ["conversationId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/conversations/{conversationId}", + "queryParams": { "include": "$include" } + } + }, + { + "name": "kustomer_update_conversation", + "description": "Update a conversation — change status (open/snoozed/done), assignee, queue, priority, tags.", + "parameters": { + "type": "object", + "properties": { + "conversationId": { "type": "string", "description": "Conversation ID." }, + "status": { "type": "string", "description": "open, snoozed, done." }, + "assignedUsers": { "type": "array", "description": "Array of user IDs." }, + "assignedTeams": { "type": "array", "description": "Array of team IDs." }, + "queue": { "type": "string", "description": "Queue ID." }, + "priority": { "type": "integer", "description": "1=Urgent, 2=High, 3=Medium, 4=Low." }, + "tags": { "type": "array", "description": "Replace tag set." } + }, + "required": ["conversationId"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/conversations/{conversationId}", + "bodyMapping": { + "status": "$status", + "assignedUsers": "$assignedUsers", + "assignedTeams": "$assignedTeams", + "queue": "$queue", + "priority": "$priority", + "tags": "$tags" + } + } + }, + { + "name": "kustomer_add_note_to_conversation", + "description": "Add an internal note (private — not sent to customer).", + "parameters": { + "type": "object", + "properties": { + "conversationId": { "type": "string", "description": "Conversation ID." }, + "body": { "type": "string", "description": "Note body." }, + "user": { "type": "string", "description": "User ID who authored." } + }, + "required": ["conversationId", "body"] + }, + "endpointMapping": { + "method": "POST", + "path": "/conversations/{conversationId}/notes", + "bodyMapping": { + "body": "$body", + "user": "$user" + } + } + }, + { + "name": "kustomer_create_outbound_message", + "description": "Send a public message in a conversation (email/SMS/chat reply). The exact required fields depend on channel.", + "parameters": { + "type": "object", + "properties": { + "conversationId": { "type": "string", "description": "Conversation ID." }, + "channel": { "type": "string", "description": "email, sms, chat." }, + "to": { "type": "string", "description": "Recipient address (email/phone)." }, + "from": { "type": "string", "description": "Sender address (verified)." }, + "subject": { "type": "string", "description": "Email subject." }, + "body": { "type": "string", "description": "Plain-text body." }, + "html": { "type": "string", "description": "HTML body (email)." } + }, + "required": ["conversationId", "channel", "to", "from", "body"] + }, + "endpointMapping": { + "method": "POST", + "path": "/conversations/{conversationId}/messages", + "bodyMapping": { + "channel": "$channel", + "to": "$to", + "from": "$from", + "subject": "$subject", + "body": "$body", + "html": "$html" + } + } + }, + { + "name": "kustomer_list_users", + "description": "List org users (agents).", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/users" } + } + ] +} diff --git a/packages/backend/src/adapters/intl/kustomer.live.spec.ts b/packages/backend/src/adapters/intl/kustomer.live.spec.ts new file mode 100644 index 0000000..16f640e --- /dev/null +++ b/packages/backend/src/adapters/intl/kustomer.live.spec.ts @@ -0,0 +1,8 @@ +import * as adapter from './kustomer.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string } }; +describe('kustomer adapter — static spec conformance', () => { + it('subdomain-templated base URL', () => { + expect(a.connector.baseUrl).toBe('https://{{KUSTOMER_SUBDOMAIN}}.api.kustomerapp.com/v1'); + }); + it('Bearer auth', () => expect(a.connector.authType).toBe('BEARER_TOKEN')); +}); diff --git a/packages/backend/src/adapters/intl/microsoft-bookings.json b/packages/backend/src/adapters/intl/microsoft-bookings.json new file mode 100644 index 0000000..20184c2 --- /dev/null +++ b/packages/backend/src/adapters/intl/microsoft-bookings.json @@ -0,0 +1,211 @@ +{ + "slug": "microsoft-bookings", + "name": "Microsoft Bookings", + "description": "Drive Microsoft Bookings (Microsoft 365 scheduling product) via the Graph API: businesses, services, staff, appointments, customers. 8 tools, OAuth2 Bearer auth.", + "instructions": "This connector uses the Microsoft Graph API v1.0 \u2014 Bookings endpoints (learn.microsoft.com/en-us/graph/api/resources/booking-api-overview).\n\n**Setup**:\n1. Register an Azure AD app at https://portal.azure.com \u2192 **App registrations \u2192 New registration**.\n2. **API permissions \u2192 Microsoft Graph \u2192 Delegated** (or Application) \u2192 add: `Bookings.Read.All`, `BookingsAppointment.ReadWrite.All` (write needs admin consent).\n3. Run OAuth2 authorization-code (or client-credentials) flow against `login.microsoftonline.com/{tenant}/oauth2/v2.0/token` to get an access token.\n4. Set `MICROSOFT_GRAPH_ACCESS_TOKEN`.\n\n**Authentication**: `Authorization: Bearer ${MICROSOFT_GRAPH_ACCESS_TOKEN}`.\n\n**Booking Business ID**: every Microsoft Bookings 'page' is a Booking Business. Discover them via `bookings_list_businesses`. The id looks like `Contoso@yourtenant.onmicrosoft.com` (an SMTP-like address, NOT a UUID).\n\n**Public booking endpoints**: most of Microsoft Bookings is private (admin-only). The public 'self-service' booking pages are at `https://outlook.office.com/owa/calendar/{biz_id}@{tenant}/bookings/` \u2014 out of scope; agents use the admin endpoints here.\n\n**Rate limits**: Graph throttling kicks in around 10k req per 10 min per app per user. On 429 honor Retry-After.\n\n**Out of scope here**: webhooks, custom questions per service, Microsoft Bookings staff scheduling rules.", + "region": "intl", + "category": "scheduling", + "icon": "microsoft-bookings", + "docsUrl": "https://learn.microsoft.com/en-us/graph/api/resources/booking-api-overview", + "requiredEnvVars": [ + "MICROSOFT_GRAPH_ACCESS_TOKEN" + ], + "connector": { + "name": "Microsoft Graph (Bookings)", + "type": "REST", + "baseUrl": "https://graph.microsoft.com/v1.0", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{MICROSOFT_GRAPH_ACCESS_TOKEN}}" + } + }, + "tools": [ + { + "name": "microsoft_bookings_list_businesses", + "description": "List all Bookings Businesses (mailbox-style booking pages) on the tenant.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/solutions/bookingBusinesses" + } + }, + { + "name": "microsoft_bookings_get_business", + "description": "Get a Booking Business by ID with its public profile.", + "parameters": { + "type": "object", + "properties": { + "businessId": { + "type": "string", + "description": "Bookings business ID (Contoso@tenant.onmicrosoft.com)." + } + }, + "required": [ + "businessId" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/solutions/bookingBusinesses/{businessId}" + } + }, + { + "name": "microsoft_bookings_list_services", + "description": "List services (offerings) of a Booking Business. Each service has id, displayName, duration, defaultPrice, staffMemberIds[], smsNotificationsEnabled.", + "parameters": { + "type": "object", + "properties": { + "businessId": { + "type": "string", + "description": "Business ID." + } + }, + "required": [ + "businessId" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/solutions/bookingBusinesses/{businessId}/services" + } + }, + { + "name": "microsoft_bookings_list_staff_members", + "description": "List staff members of a Booking Business.", + "parameters": { + "type": "object", + "properties": { + "businessId": { + "type": "string", + "description": "Business ID." + } + }, + "required": [ + "businessId" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/solutions/bookingBusinesses/{businessId}/staffMembers" + } + }, + { + "name": "microsoft_bookings_list_appointments", + "description": "List appointments (bookings) in a Booking Business.", + "parameters": { + "type": "object", + "properties": { + "businessId": { + "type": "string", + "description": "Business ID." + }, + "$top": { + "type": "integer", + "description": "Max results per page." + }, + "$filter": { + "type": "string", + "description": "OData $filter, e.g. 'start/dateTime ge 2025-01-01T00:00:00Z'." + } + }, + "required": [ + "businessId" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/solutions/bookingBusinesses/{businessId}/appointments", + "queryParams": { + "$top": "$$top", + "$filter": "$$filter" + } + } + }, + { + "name": "microsoft_bookings_get_appointment", + "description": "Get one appointment by ID.", + "parameters": { + "type": "object", + "properties": { + "businessId": { + "type": "string", + "description": "Business ID." + }, + "appointmentId": { + "type": "string", + "description": "Appointment ID." + } + }, + "required": [ + "businessId", + "appointmentId" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/solutions/bookingBusinesses/{businessId}/appointments/{appointmentId}" + } + }, + { + "name": "microsoft_bookings_create_appointment", + "description": "Create a booked appointment. Required: serviceId + start + end + customers.", + "parameters": { + "type": "object", + "properties": { + "businessId": { + "type": "string", + "description": "Business ID." + }, + "appointment": { + "type": "object", + "description": "{serviceId, start:{dateTime:'2025-01-01T15:00:00', timeZone:'Pacific Standard Time'}, end:{...}, customers:[{customerId? OR name+emailAddress}], staffMemberIds?:[], serviceLocation?:{...}, customerNotes?, smsNotificationsEnabled?:bool, isLocationOnline?:bool}." + } + }, + "required": [ + "businessId", + "appointment" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/solutions/bookingBusinesses/{businessId}/appointments", + "bodyTemplate": "${appointment}" + } + }, + { + "name": "microsoft_bookings_cancel_appointment", + "description": "Cancel an appointment. Optional cancellationMessage.", + "parameters": { + "type": "object", + "properties": { + "businessId": { + "type": "string", + "description": "Business ID." + }, + "appointmentId": { + "type": "string", + "description": "Appointment ID." + }, + "cancellationMessage": { + "type": "string", + "description": "Message sent to the customer." + } + }, + "required": [ + "businessId", + "appointmentId" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/solutions/bookingBusinesses/{businessId}/appointments/{appointmentId}/cancel", + "bodyMapping": { + "cancellationMessage": "$cancellationMessage" + } + } + } + ] +} \ No newline at end of file diff --git a/packages/backend/src/adapters/intl/microsoft-bookings.live.spec.ts b/packages/backend/src/adapters/intl/microsoft-bookings.live.spec.ts new file mode 100644 index 0000000..79fcd16 --- /dev/null +++ b/packages/backend/src/adapters/intl/microsoft-bookings.live.spec.ts @@ -0,0 +1,6 @@ +import * as adapter from './microsoft-bookings.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string } }; +describe('microsoft-bookings adapter — static spec conformance', () => { + it('graph.microsoft.com/v1.0', () => expect(a.connector.baseUrl).toBe('https://graph.microsoft.com/v1.0')); + it('Bearer auth (Microsoft Graph access token)', () => expect(a.connector.authType).toBe('BEARER_TOKEN')); +}); diff --git a/packages/backend/src/adapters/intl/vercel-analytics.json b/packages/backend/src/adapters/intl/vercel-analytics.json new file mode 100644 index 0000000..45c608e --- /dev/null +++ b/packages/backend/src/adapters/intl/vercel-analytics.json @@ -0,0 +1,103 @@ +{ + "slug": "vercel-analytics", + "name": "Vercel Analytics", + "description": "Read Vercel Analytics / Speed Insights / Audience data via the Vercel API. 4 tools, Bearer auth.", + "instructions": "This connector uses the Vercel Platform API (vercel.com/docs/rest-api).\n\n**Setup**:\n1. Sign in to https://vercel.com → top-right avatar → **Settings → Tokens → Create Token**.\n2. Pick scope (account-level or team-level). Set `VERCEL_ACCESS_TOKEN`.\n3. For team-scoped queries also set `VERCEL_TEAM_ID` (visible in Team Settings).\n\n**Authentication**: `Authorization: Bearer ${VERCEL_ACCESS_TOKEN}`.\n\n**Note**: Vercel's Web Analytics + Speed Insights data endpoints went through major changes in 2024-2025. Some endpoints are paywall/plan-gated. The adapter uses the stable public endpoints; some analytics queries require Pro+ plan.\n\n**Pagination**: cursor-based via `until` Unix ms (return items older than this).\n\n**Out of scope here**: deployments/builds CRUD (large surface, separate connector if needed), Edge Config, KV/Postgres data, Edge Functions logs.", + "region": "intl", + "category": "analytics", + "icon": "vercel", + "docsUrl": "https://vercel.com/docs/rest-api", + "requiredEnvVars": ["VERCEL_ACCESS_TOKEN"], + "connector": { + "name": "Vercel API", + "type": "REST", + "baseUrl": "https://api.vercel.com", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{VERCEL_ACCESS_TOKEN}}" + } + }, + "tools": [ + { + "name": "vercel_analytics_get_user", + "description": "Return the user the token belongs to.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/v2/user" } + }, + { + "name": "vercel_analytics_list_projects", + "description": "List projects in the team / account.", + "parameters": { + "type": "object", + "properties": { + "teamId": { "type": "string", "description": "Team ID (omit for personal account)." }, + "limit": { "type": "integer", "description": "Per page (max 100)." }, + "from": { "type": "integer", "description": "Unix ms — projects created after." }, + "search": { "type": "string", "description": "Substring filter on project name." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v9/projects", + "queryParams": { + "teamId": "$teamId", + "limit": "$limit", + "from": "$from", + "search": "$search" + } + } + }, + { + "name": "vercel_analytics_get_deployments", + "description": "List deployments for a project. Returns each deployment's url, state, source, created.", + "parameters": { + "type": "object", + "properties": { + "projectId": { "type": "string", "description": "Project ID or name." }, + "teamId": { "type": "string", "description": "Team ID if team-scoped." }, + "limit": { "type": "integer", "description": "Per page (default 20, max 100)." }, + "state": { "type": "string", "description": "BUILDING, ERROR, INITIALIZING, QUEUED, READY, CANCELED." }, + "until": { "type": "integer", "description": "Unix ms — deployments before." }, + "since": { "type": "integer", "description": "Unix ms — deployments after." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/v6/deployments", + "queryParams": { + "projectId": "$projectId", + "teamId": "$teamId", + "limit": "$limit", + "state": "$state", + "until": "$until", + "since": "$since" + } + } + }, + { + "name": "vercel_analytics_get_speed_insights", + "description": "Get Speed Insights (Core Web Vitals) data for a project — requires Vercel Speed Insights enabled on the project + Pro plan.", + "parameters": { + "type": "object", + "properties": { + "projectId": { "type": "string", "description": "Project ID." }, + "teamId": { "type": "string", "description": "Team ID." }, + "from": { "type": "integer", "description": "Unix ms range start." }, + "to": { "type": "integer", "description": "Unix ms range end." }, + "interval": { "type": "string", "description": "1h, 1d, 7d, 30d." } + }, + "required": ["projectId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/web-analytics/v1/projects/{projectId}/speed-insights", + "queryParams": { + "teamId": "$teamId", + "from": "$from", + "to": "$to", + "interval": "$interval" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/vercel-analytics.live.spec.ts b/packages/backend/src/adapters/intl/vercel-analytics.live.spec.ts new file mode 100644 index 0000000..c713cf7 --- /dev/null +++ b/packages/backend/src/adapters/intl/vercel-analytics.live.spec.ts @@ -0,0 +1,6 @@ +import * as adapter from './vercel-analytics.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string } }; +describe('vercel-analytics adapter — static spec conformance', () => { + it('api.vercel.com', () => expect(a.connector.baseUrl).toBe('https://api.vercel.com')); + it('Bearer auth', () => expect(a.connector.authType).toBe('BEARER_TOKEN')); +}); From b17b1861f776f42206d4cfde0efe53cdafbfc050 Mon Sep 17 00:00:00 2001 From: Matteo Date: Wed, 20 May 2026 13:34:35 +0200 Subject: [PATCH 18/19] connectors: add Mailshake, Microsoft Teams, LinkedIn, Amadeus (finale) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 15 — final batch of the greenfield catalog growth. - Mailshake: 8 tools — campaigns list/get/pause/unpause, recipients add (with dedupe) / list / pause. BASIC_AUTH with key as user. - Microsoft Teams (via Graph v1.0): 9 tools — joined teams, channels list/get, messages list/send/reply with HTML body + mentions[], channel + team members. Shares MICROSOFT_GRAPH_ACCESS_TOKEN env var with the Bookings adapter. - LinkedIn v2: 5 tools — userinfo (OIDC), create/get/delete post for w_member_social scope, register image upload. Documents the Marketing Developer Platform gating limitation that blocks most other useful endpoints. - Amadeus Self-Service (travel): 8 tools — flight offers search + price confirmation + destinations inspiration, airport/city autocomplete, hotel list + offers, POIs, flight status. OAuth2 client-credentials Bearer. Test environment by default. Catalog: 119 adapters total (79/81 of the greenfield batch — the plan called for 81 minus the 1 (WooCommerce) that landed via external PR while I was working). This closes the greenfield connector batch. PR #232 is ready for final review + merge once CI passes. --- packages/backend/src/adapters/catalog.ts | 8 + .../backend/src/adapters/intl/amadeus.json | 239 ++++++++++++++++++ .../src/adapters/intl/amadeus.live.spec.ts | 8 + .../backend/src/adapters/intl/linkedin.json | 135 ++++++++++ .../src/adapters/intl/linkedin.live.spec.ts | 14 + .../backend/src/adapters/intl/mailshake.json | 168 ++++++++++++ .../src/adapters/intl/mailshake.live.spec.ts | 9 + .../src/adapters/intl/microsoft-teams.json | 156 ++++++++++++ .../intl/microsoft-teams.live.spec.ts | 6 + 9 files changed, 743 insertions(+) create mode 100644 packages/backend/src/adapters/intl/amadeus.json create mode 100644 packages/backend/src/adapters/intl/amadeus.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/linkedin.json create mode 100644 packages/backend/src/adapters/intl/linkedin.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/mailshake.json create mode 100644 packages/backend/src/adapters/intl/mailshake.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/microsoft-teams.json create mode 100644 packages/backend/src/adapters/intl/microsoft-teams.live.spec.ts diff --git a/packages/backend/src/adapters/catalog.ts b/packages/backend/src/adapters/catalog.ts index b1eb223..ca22800 100644 --- a/packages/backend/src/adapters/catalog.ts +++ b/packages/backend/src/adapters/catalog.ts @@ -34,6 +34,7 @@ import * as wise from './gb/wise.json'; import * as activecampaign from './intl/activecampaign.json'; import * as acuityScheduling from './intl/acuity-scheduling.json'; import * as adyen from './intl/adyen.json'; +import * as amadeus from './intl/amadeus.json'; import * as apollo from './intl/apollo.json'; import * as attio from './intl/attio.json'; import * as basecamp from './intl/basecamp.json'; @@ -71,13 +72,16 @@ import * as klaviyo from './intl/klaviyo.json'; import * as kustomer from './intl/kustomer.json'; import * as lemlist from './intl/lemlist.json'; import * as lemonsqueezy from './intl/lemonsqueezy.json'; +import * as linkedin from './intl/linkedin.json'; import * as loops from './intl/loops.json'; import * as magento from './intl/magento.json'; import * as mailchimp from './intl/mailchimp.json'; +import * as mailshake from './intl/mailshake.json'; import * as mapbox from './intl/mapbox.json'; import * as medium from './intl/medium.json'; import * as messagebird from './intl/messagebird.json'; import * as microsoftBookings from './intl/microsoft-bookings.json'; +import * as microsoftTeams from './intl/microsoft-teams.json'; import * as mintlify from './intl/mintlify.json'; import * as mollie from './intl/mollie.json'; import * as neverbounce from './intl/neverbounce.json'; @@ -221,6 +225,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ activecampaign as unknown as AdapterDefinition, acuityScheduling as unknown as AdapterDefinition, adyen as unknown as AdapterDefinition, + amadeus as unknown as AdapterDefinition, apollo as unknown as AdapterDefinition, attio as unknown as AdapterDefinition, basecamp as unknown as AdapterDefinition, @@ -258,13 +263,16 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ kustomer as unknown as AdapterDefinition, lemlist as unknown as AdapterDefinition, lemonsqueezy as unknown as AdapterDefinition, + linkedin as unknown as AdapterDefinition, loops as unknown as AdapterDefinition, magento as unknown as AdapterDefinition, mailchimp as unknown as AdapterDefinition, + mailshake as unknown as AdapterDefinition, mapbox as unknown as AdapterDefinition, medium as unknown as AdapterDefinition, messagebird as unknown as AdapterDefinition, microsoftBookings as unknown as AdapterDefinition, + microsoftTeams as unknown as AdapterDefinition, mintlify as unknown as AdapterDefinition, mollie as unknown as AdapterDefinition, neverbounce as unknown as AdapterDefinition, diff --git a/packages/backend/src/adapters/intl/amadeus.json b/packages/backend/src/adapters/intl/amadeus.json new file mode 100644 index 0000000..db71f9b --- /dev/null +++ b/packages/backend/src/adapters/intl/amadeus.json @@ -0,0 +1,239 @@ +{ + "slug": "amadeus", + "name": "Amadeus for Developers", + "description": "Drive Amadeus Travel APIs (flight offers, hotel search, airport autocomplete, points of interest) from any AI agent. 8 tools, OAuth2 client-credentials Bearer auth.", + "instructions": "This connector uses the Amadeus Self-Service REST APIs (developers.amadeus.com).\n\n**Setup**:\n1. Register at https://developers.amadeus.com → **My Self-Service Workspace → Create New App**.\n2. Get **API Key** + **API Secret**. Use the **test** environment by default (free, real data, low rate limits); switch to production after applying for production access.\n3. Run OAuth2 client_credentials flow to get an access token (~30 min expiry):\n ```\n POST https://test.api.amadeus.com/v1/security/oauth2/token\n grant_type=client_credentials&client_id=...&client_secret=...\n ```\n4. Set `AMADEUS_ACCESS_TOKEN`. Refresh externally.\n\n**Authentication**: `Authorization: Bearer ${AMADEUS_ACCESS_TOKEN}`.\n\n**Test vs Production base URL**:\n - Test: `https://test.api.amadeus.com`\n - Production: `https://api.amadeus.com`\nThe adapter uses test by default; switch to production by overriding `AMADEUS_BASE_URL` env var (not exposed as a connector field — change baseUrl in your fork).\n\n**Date format**: ISO 8601 (YYYY-MM-DD).\n\n**Currency / Locale**: most search endpoints accept `currencyCode` (ISO 4217) and locale-specific responses.\n\n**Rate limits**: test environment is heavily throttled (~10 req/sec). On 429 back off significantly.\n\n**Out of scope here**: booking flow (Flight Create Orders requires special partnership), Self-Service vs Enterprise APIs differences, Amadeus Travel Channel API, hotel ratings via Sabre (different vendor).", + "region": "intl", + "category": "travel", + "icon": "amadeus", + "docsUrl": "https://developers.amadeus.com/self-service", + "requiredEnvVars": ["AMADEUS_ACCESS_TOKEN"], + "connector": { + "name": "Amadeus Self-Service", + "type": "REST", + "baseUrl": "https://test.api.amadeus.com", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{AMADEUS_ACCESS_TOKEN}}" + } + }, + "tools": [ + { + "name": "amadeus_flight_offers_search", + "description": "Search for flight offers. Returns ranked itineraries with price, segments, validatingAirlineCodes, travelerPricings.", + "parameters": { + "type": "object", + "properties": { + "originLocationCode": { "type": "string", "description": "IATA airport/city code (e.g. 'JFK', 'NYC')." }, + "destinationLocationCode": { "type": "string", "description": "IATA airport/city code (e.g. 'LHR', 'LON')." }, + "departureDate": { "type": "string", "description": "YYYY-MM-DD." }, + "returnDate": { "type": "string", "description": "YYYY-MM-DD — omit for one-way." }, + "adults": { "type": "integer", "description": "Number of adults (default 1)." }, + "children": { "type": "integer", "description": "Number of children (2-11 years)." }, + "infants": { "type": "integer", "description": "Infants (<2 years)." }, + "travelClass": { "type": "string", "description": "ECONOMY, PREMIUM_ECONOMY, BUSINESS, FIRST." }, + "includedAirlineCodes": { "type": "string", "description": "Comma-separated airline codes to include." }, + "excludedAirlineCodes": { "type": "string", "description": "Comma-separated to exclude." }, + "nonStop": { "type": "boolean", "description": "If true, only non-stop." }, + "currencyCode": { "type": "string", "description": "ISO 4217." }, + "maxPrice": { "type": "integer", "description": "Max price per traveler." }, + "max": { "type": "integer", "description": "Max offers returned (1-250, default 250)." } + }, + "required": ["originLocationCode", "destinationLocationCode", "departureDate", "adults"] + }, + "endpointMapping": { + "method": "GET", + "path": "/v2/shopping/flight-offers", + "queryParams": { + "originLocationCode": "$originLocationCode", + "destinationLocationCode": "$destinationLocationCode", + "departureDate": "$departureDate", + "returnDate": "$returnDate", + "adults": "$adults", + "children": "$children", + "infants": "$infants", + "travelClass": "$travelClass", + "includedAirlineCodes": "$includedAirlineCodes", + "excludedAirlineCodes": "$excludedAirlineCodes", + "nonStop": "$nonStop", + "currencyCode": "$currencyCode", + "maxPrice": "$maxPrice", + "max": "$max" + } + } + }, + { + "name": "amadeus_flight_offer_price", + "description": "Confirm the price + availability of a previously-found flight offer. POST the flight offer back to validate before booking.", + "parameters": { + "type": "object", + "properties": { + "data": { + "type": "object", + "description": "{type:'flight-offers-pricing', flightOffers:[...]} — wrap the flight offer(s) from search." + } + }, + "required": ["data"] + }, + "endpointMapping": { + "method": "POST", + "path": "/v1/shopping/flight-offers/pricing", + "bodyMapping": { "data": "$data" } + } + }, + { + "name": "amadeus_flight_destinations", + "description": "Cheapest destinations from a city (inspirational search). Required: origin.", + "parameters": { + "type": "object", + "properties": { + "origin": { "type": "string", "description": "IATA city code." }, + "departureDate": { "type": "string", "description": "YYYY-MM-DD or YYYY-MM-DD,YYYY-MM-DD range." }, + "oneWay": { "type": "boolean", "description": "Default false (round trip)." }, + "duration": { "type": "string", "description": "Duration of stay range, e.g. '1,15' days." }, + "nonStop": { "type": "boolean", "description": "Direct only." }, + "maxPrice": { "type": "integer", "description": "Max price." }, + "currencyCode": { "type": "string", "description": "ISO 4217." } + }, + "required": ["origin"] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/shopping/flight-destinations", + "queryParams": { + "origin": "$origin", + "departureDate": "$departureDate", + "oneWay": "$oneWay", + "duration": "$duration", + "nonStop": "$nonStop", + "maxPrice": "$maxPrice", + "currencyCode": "$currencyCode" + } + } + }, + { + "name": "amadeus_airport_search", + "description": "Search for airports/cities by keyword (autocomplete).", + "parameters": { + "type": "object", + "properties": { + "keyword": { "type": "string", "description": "Substring of city or airport name." }, + "subType": { "type": "string", "description": "AIRPORT, CITY, or AIRPORT,CITY." }, + "page_limit": { "type": "integer", "description": "Max results (default 10, max 100)." }, + "view": { "type": "string", "description": "FULL or LIGHT." } + }, + "required": ["keyword", "subType"] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/reference-data/locations", + "queryParams": { + "keyword": "$keyword", + "subType": "$subType", + "page[limit]": "$page_limit", + "view": "$view" + } + } + }, + { + "name": "amadeus_hotel_list_by_city", + "description": "List hotels in a city (returns hotelIds you can use in hotel offer search).", + "parameters": { + "type": "object", + "properties": { + "cityCode": { "type": "string", "description": "IATA city code (e.g. 'PAR' for Paris)." }, + "radius": { "type": "integer", "description": "Search radius (default 5)." }, + "radiusUnit": { "type": "string", "description": "KM or MILE." }, + "ratings": { "type": "string", "description": "Comma-separated 1-5 star ratings." } + }, + "required": ["cityCode"] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/reference-data/locations/hotels/by-city", + "queryParams": { + "cityCode": "$cityCode", + "radius": "$radius", + "radiusUnit": "$radiusUnit", + "ratings": "$ratings" + } + } + }, + { + "name": "amadeus_hotel_search_offers", + "description": "Search hotel offers (available rates) for a set of hotelIds + check-in/out dates.", + "parameters": { + "type": "object", + "properties": { + "hotelIds": { "type": "string", "description": "Comma-separated hotelIds (from hotel_list_by_city)." }, + "adults": { "type": "integer", "description": "Number of adults." }, + "checkInDate": { "type": "string", "description": "YYYY-MM-DD." }, + "checkOutDate": { "type": "string", "description": "YYYY-MM-DD." }, + "roomQuantity": { "type": "integer", "description": "Number of rooms." }, + "currency": { "type": "string", "description": "ISO 4217." }, + "lang": { "type": "string", "description": "Language code." } + }, + "required": ["hotelIds", "adults", "checkInDate", "checkOutDate"] + }, + "endpointMapping": { + "method": "GET", + "path": "/v3/shopping/hotel-offers", + "queryParams": { + "hotelIds": "$hotelIds", + "adults": "$adults", + "checkInDate": "$checkInDate", + "checkOutDate": "$checkOutDate", + "roomQuantity": "$roomQuantity", + "currency": "$currency", + "lang": "$lang" + } + } + }, + { + "name": "amadeus_points_of_interest", + "description": "Get points of interest around a location (sightseeing/attractions). Returns POIs with name, category, rank, tags.", + "parameters": { + "type": "object", + "properties": { + "latitude": { "type": "number", "description": "Latitude." }, + "longitude": { "type": "number", "description": "Longitude." }, + "radius": { "type": "integer", "description": "Search radius in km (default 1, max 20)." }, + "categories": { "type": "string", "description": "Comma-separated: SIGHTS, NIGHTLIFE, RESTAURANT, SHOPPING." } + }, + "required": ["latitude", "longitude"] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/reference-data/locations/pois", + "queryParams": { + "latitude": "$latitude", + "longitude": "$longitude", + "radius": "$radius", + "categories": "$categories" + } + } + }, + { + "name": "amadeus_flight_status", + "description": "Get real-time flight status by carrier code + flight number + date.", + "parameters": { + "type": "object", + "properties": { + "carrierCode": { "type": "string", "description": "Airline IATA code (e.g. 'BA' for British Airways)." }, + "flightNumber": { "type": "string", "description": "Flight number (digits only)." }, + "scheduledDepartureDate": { "type": "string", "description": "YYYY-MM-DD." } + }, + "required": ["carrierCode", "flightNumber", "scheduledDepartureDate"] + }, + "endpointMapping": { + "method": "GET", + "path": "/v2/schedule/flights", + "queryParams": { + "carrierCode": "$carrierCode", + "flightNumber": "$flightNumber", + "scheduledDepartureDate": "$scheduledDepartureDate" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/amadeus.live.spec.ts b/packages/backend/src/adapters/intl/amadeus.live.spec.ts new file mode 100644 index 0000000..ddbe887 --- /dev/null +++ b/packages/backend/src/adapters/intl/amadeus.live.spec.ts @@ -0,0 +1,8 @@ +import * as adapter from './amadeus.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string } }; +describe('amadeus adapter — static spec conformance', () => { + it('test.api.amadeus.com (test env by default)', () => + expect(a.connector.baseUrl).toBe('https://test.api.amadeus.com')); + it('Bearer auth (OAuth2 client-credentials access token)', () => + expect(a.connector.authType).toBe('BEARER_TOKEN')); +}); diff --git a/packages/backend/src/adapters/intl/linkedin.json b/packages/backend/src/adapters/intl/linkedin.json new file mode 100644 index 0000000..6275389 --- /dev/null +++ b/packages/backend/src/adapters/intl/linkedin.json @@ -0,0 +1,135 @@ +{ + "slug": "linkedin", + "name": "LinkedIn", + "description": "Drive LinkedIn (profile + shared posts) for the authenticated user from any AI agent. Limited scope: most LinkedIn features require Marketing Developer Platform approval. 5 tools, OAuth2 Bearer auth.", + "instructions": "This connector uses the LinkedIn Marketing/Profile API v2 (learn.microsoft.com/en-us/linkedin/).\n\n**HEAVY GATING**: LinkedIn's API is one of the most restricted public APIs. Most useful endpoints (read posts, search profiles, fetch connections, manage company pages) require **Marketing Developer Platform approval** from LinkedIn — a partnership program with significant requirements (existing app + revenue commitments).\n\nWhat WORKS without partnership:\n - 'Sign in with LinkedIn using OpenID Connect' product scopes: `openid`, `profile`, `email`, `w_member_social` (post on behalf of user).\n\n**Setup**:\n1. Register an app at https://www.linkedin.com/developers/apps.\n2. Add the **Sign In with LinkedIn using OpenID Connect** product.\n3. (Optional) Add **Share on LinkedIn** product for posting.\n4. Run OAuth2 authorization-code flow with scopes `openid profile email w_member_social`.\n5. Set `LINKEDIN_ACCESS_TOKEN`.\n\n**Authentication**: `Authorization: Bearer ${LINKEDIN_ACCESS_TOKEN}`.\n\n**LinkedIn-Version header**: required on most v2 endpoints: `LinkedIn-Version: 202405` (use the YYYYMM of the API version you're targeting). Without it many endpoints return 426 Upgrade Required.\n\n**Person URN**: shares are posted as `urn:li:person:{your_person_id}`. Get your person_id from the userinfo endpoint.\n\n**Out of scope here** (require Marketing Developer Platform): read other users' posts, search profiles, manage company pages, ad campaigns, analytics, posts inbox.", + "region": "intl", + "category": "social", + "icon": "linkedin", + "docsUrl": "https://learn.microsoft.com/en-us/linkedin/", + "requiredEnvVars": ["LINKEDIN_ACCESS_TOKEN"], + "connector": { + "name": "LinkedIn v2", + "type": "REST", + "baseUrl": "https://api.linkedin.com/v2", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{LINKEDIN_ACCESS_TOKEN}}" + } + }, + "tools": [ + { + "name": "linkedin_userinfo", + "description": "Get the authenticated user's profile (OpenID Connect userinfo endpoint). Returns sub (person ID), name, given_name, family_name, picture, locale, email, email_verified.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { + "method": "GET", + "path": "/userinfo", + "headers": { "LinkedIn-Version": "202405" } + } + }, + { + "name": "linkedin_create_post", + "description": "Share a text or article post on behalf of the user. Required: author (URN) + commentary (text). LinkedIn requires `LinkedIn-Version` and `X-Restli-Protocol-Version: 2.0.0` headers (pinned per-tool).", + "parameters": { + "type": "object", + "properties": { + "author": { "type": "string", "description": "URN of the author, typically 'urn:li:person:YOUR_PERSON_ID' (get from userinfo's sub field)." }, + "commentary": { "type": "string", "description": "Post body text. Max 3000 chars." }, + "visibility": { "type": "string", "description": "PUBLIC (default) or CONNECTIONS." }, + "lifecycleState": { "type": "string", "description": "PUBLISHED (default) or DRAFT." }, + "distribution": { + "type": "object", + "description": "{feedDistribution:'MAIN_FEED'|'NONE', targetEntities:[], thirdPartyDistributionChannels:[]}." + }, + "isReshareDisabledByAuthor": { "type": "boolean", "description": "Disable reshares." }, + "content": { + "type": "object", + "description": "Optional content: {article:{source:'URL'}} for link post OR {media:{id:'urn:li:image:...'}} for image post (image upload requires a separate registerUpload call out of scope here)." + } + }, + "required": ["author", "commentary"] + }, + "endpointMapping": { + "method": "POST", + "path": "/posts", + "headers": { + "LinkedIn-Version": "202405", + "X-Restli-Protocol-Version": "2.0.0" + }, + "bodyMapping": { + "author": "$author", + "commentary": "$commentary", + "visibility": "$visibility", + "lifecycleState": "$lifecycleState", + "distribution": "$distribution", + "isReshareDisabledByAuthor": "$isReshareDisabledByAuthor", + "content": "$content" + } + } + }, + { + "name": "linkedin_get_post", + "description": "Get a previously-created post by URN (you can only fetch posts you authored unless you have higher-tier scopes).", + "parameters": { + "type": "object", + "properties": { + "postUrn": { "type": "string", "description": "Post URN, URL-encoded. e.g. 'urn%3Ali%3Ashare%3A12345'." } + }, + "required": ["postUrn"] + }, + "endpointMapping": { + "method": "GET", + "path": "/posts/{postUrn}", + "headers": { + "LinkedIn-Version": "202405", + "X-Restli-Protocol-Version": "2.0.0" + } + } + }, + { + "name": "linkedin_delete_post", + "description": "Delete a post you authored.", + "parameters": { + "type": "object", + "properties": { + "postUrn": { "type": "string", "description": "Post URN URL-encoded." } + }, + "required": ["postUrn"] + }, + "endpointMapping": { + "method": "DELETE", + "path": "/posts/{postUrn}", + "headers": { + "LinkedIn-Version": "202405", + "X-Restli-Protocol-Version": "2.0.0" + } + } + }, + { + "name": "linkedin_register_image_upload", + "description": "Step 1 of image upload: register an upload. Returns an uploadUrl + image URN. You then PUT the binary to the uploadUrl out-of-band, then use the image URN in linkedin_create_post's content.media.", + "parameters": { + "type": "object", + "properties": { + "initializeUploadRequest": { + "type": "object", + "description": "{owner: 'urn:li:person:YOUR_PERSON_ID'}." + } + }, + "required": ["initializeUploadRequest"] + }, + "endpointMapping": { + "method": "POST", + "path": "/images?action=initializeUpload", + "headers": { + "LinkedIn-Version": "202405", + "X-Restli-Protocol-Version": "2.0.0" + }, + "bodyMapping": { + "initializeUploadRequest": "$initializeUploadRequest" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/linkedin.live.spec.ts b/packages/backend/src/adapters/intl/linkedin.live.spec.ts new file mode 100644 index 0000000..69328fc --- /dev/null +++ b/packages/backend/src/adapters/intl/linkedin.live.spec.ts @@ -0,0 +1,14 @@ +import * as adapter from './linkedin.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string }; + tools: Array<{ endpointMapping: { headers?: Record } }>; +}; +describe('linkedin adapter — static spec conformance', () => { + it('api.linkedin.com/v2', () => expect(a.connector.baseUrl).toBe('https://api.linkedin.com/v2')); + it('Bearer auth', () => expect(a.connector.authType).toBe('BEARER_TOKEN')); + it('every tool pins LinkedIn-Version header', () => { + for (const t of a.tools) { + expect(t.endpointMapping.headers?.['LinkedIn-Version']).toBeTruthy(); + } + }); +}); diff --git a/packages/backend/src/adapters/intl/mailshake.json b/packages/backend/src/adapters/intl/mailshake.json new file mode 100644 index 0000000..9627c5d --- /dev/null +++ b/packages/backend/src/adapters/intl/mailshake.json @@ -0,0 +1,168 @@ +{ + "slug": "mailshake", + "name": "Mailshake", + "description": "Drive Mailshake (cold email / sales engagement) from any AI agent: campaigns, recipients, leads, replies. 8 tools, Basic auth (API key as user).", + "instructions": "This connector uses the Mailshake API v1 (mailshake.com/api/v1).\n\n**Setup**:\n1. Sign in to https://mailshake.com → top-right avatar → **API Keys → Create API Key**.\n2. Set `MAILSHAKE_API_KEY`.\n\n**Authentication**: HTTP Basic with username=API_KEY, password=empty.\n\n**Campaign**: a sequence of email steps. Add recipients to a campaign → they start receiving emails on schedule.\n\n**Recipient state**: each recipient has a status (pending, sending, paused, finished, replied, bounced, unsubscribed).\n\n**Pagination**: `?perPage=N&cursor=...`.\n\n**Out of scope here**: campaign content editor (UI), team management, AI features, copy-A/B-testing config.", + "region": "intl", + "category": "crm", + "icon": "mailshake", + "docsUrl": "https://mailshake.com/api/v1/", + "requiredEnvVars": ["MAILSHAKE_API_KEY"], + "connector": { + "name": "Mailshake v1", + "type": "REST", + "baseUrl": "https://api.mailshake.com/2017-04-01", + "authType": "BASIC_AUTH", + "authConfig": { + "username": "{{MAILSHAKE_API_KEY}}", + "password": "" + } + }, + "tools": [ + { + "name": "mailshake_me", + "description": "Return the team the API key belongs to.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "POST", "path": "/users/me" } + }, + { + "name": "mailshake_list_campaigns", + "description": "List campaigns.", + "parameters": { + "type": "object", + "properties": { + "perPage": { "type": "integer", "description": "Per page (default 25, max 100)." }, + "cursor": { "type": "string", "description": "Pagination cursor." }, + "search": { "type": "string", "description": "Name substring filter." } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/campaigns/list", + "bodyMapping": { + "perPage": "$perPage", + "cursor": "$cursor", + "search": "$search" + } + } + }, + { + "name": "mailshake_get_campaign", + "description": "Get a campaign by ID.", + "parameters": { + "type": "object", + "properties": { + "id": { "type": "integer", "description": "Campaign ID." } + }, + "required": ["id"] + }, + "endpointMapping": { + "method": "POST", + "path": "/campaigns/get", + "bodyMapping": { "id": "$id" } + } + }, + { + "name": "mailshake_pause_campaign", + "description": "Pause a campaign (stops all future sends until resumed).", + "parameters": { + "type": "object", + "properties": { + "id": { "type": "integer", "description": "Campaign ID." } + }, + "required": ["id"] + }, + "endpointMapping": { + "method": "POST", + "path": "/campaigns/pause", + "bodyMapping": { "id": "$id" } + } + }, + { + "name": "mailshake_unpause_campaign", + "description": "Resume a paused campaign.", + "parameters": { + "type": "object", + "properties": { + "id": { "type": "integer", "description": "Campaign ID." } + }, + "required": ["id"] + }, + "endpointMapping": { + "method": "POST", + "path": "/campaigns/unpause", + "bodyMapping": { "id": "$id" } + } + }, + { + "name": "mailshake_add_recipients_to_campaign", + "description": "Add recipients to a campaign. recipients is array of {emailAddress, fullName?, firstName?, lastName?, phone?, companyName?, ...customFields}.", + "parameters": { + "type": "object", + "properties": { + "campaignID": { "type": "integer", "description": "Campaign ID." }, + "recipients": { + "type": "array", + "description": "[{emailAddress, fullName?, firstName?, lastName?, phone?, companyName?, ...any custom variables defined in the campaign}]." + }, + "addDuplicates": { "type": "boolean", "description": "If true, add even if email already in campaign." } + }, + "required": ["campaignID", "recipients"] + }, + "endpointMapping": { + "method": "POST", + "path": "/recipients/add", + "bodyMapping": { + "campaignID": "$campaignID", + "recipients": "$recipients", + "addDuplicates": "$addDuplicates" + } + } + }, + { + "name": "mailshake_list_recipients", + "description": "List recipients in a campaign with filters.", + "parameters": { + "type": "object", + "properties": { + "campaignID": { "type": "integer", "description": "Campaign ID." }, + "perPage": { "type": "integer", "description": "Per page." }, + "cursor": { "type": "string", "description": "Cursor." }, + "status": { "type": "string", "description": "pending, sending, paused, finished, replied, bounced, unsubscribed." }, + "search": { "type": "string", "description": "Substring filter." } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/recipients/list", + "bodyMapping": { + "campaignID": "$campaignID", + "perPage": "$perPage", + "cursor": "$cursor", + "status": "$status", + "search": "$search" + } + } + }, + { + "name": "mailshake_pause_recipient", + "description": "Pause a recipient by emailAddress + campaignID (stops further emails to them without unsubscribing).", + "parameters": { + "type": "object", + "properties": { + "emailAddress": { "type": "string", "description": "Recipient email." }, + "campaignID": { "type": "integer", "description": "Campaign ID." } + }, + "required": ["emailAddress", "campaignID"] + }, + "endpointMapping": { + "method": "POST", + "path": "/recipients/pause", + "bodyMapping": { + "emailAddress": "$emailAddress", + "campaignID": "$campaignID" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/mailshake.live.spec.ts b/packages/backend/src/adapters/intl/mailshake.live.spec.ts new file mode 100644 index 0000000..8927681 --- /dev/null +++ b/packages/backend/src/adapters/intl/mailshake.live.spec.ts @@ -0,0 +1,9 @@ +import * as adapter from './mailshake.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string; authConfig: Record } }; +describe('mailshake adapter — static spec conformance', () => { + it('api.mailshake.com/2017-04-01', () => expect(a.connector.baseUrl).toBe('https://api.mailshake.com/2017-04-01')); + it('Basic auth with key as user', () => { + expect(a.connector.authConfig.username).toBe('{{MAILSHAKE_API_KEY}}'); + expect(a.connector.authConfig.password).toBe(''); + }); +}); diff --git a/packages/backend/src/adapters/intl/microsoft-teams.json b/packages/backend/src/adapters/intl/microsoft-teams.json new file mode 100644 index 0000000..54a35ff --- /dev/null +++ b/packages/backend/src/adapters/intl/microsoft-teams.json @@ -0,0 +1,156 @@ +{ + "slug": "microsoft-teams", + "name": "Microsoft Teams", + "description": "Drive Microsoft Teams (chat + collaboration) via the Graph API: teams, channels, messages, members. 9 tools, OAuth2 Bearer auth (Microsoft Graph).", + "instructions": "This connector uses the Microsoft Graph API v1.0 — Teams endpoints (learn.microsoft.com/en-us/graph/api/resources/teams-api-overview).\n\n**Setup**:\n1. Register an Azure AD app at https://portal.azure.com → **App registrations**.\n2. **API permissions → Microsoft Graph → Delegated** (or Application) → add: `Team.ReadBasic.All`, `Channel.ReadBasic.All`, `ChannelMessage.Send`, `ChannelMessage.Read.All` (write needs admin consent).\n3. Run OAuth2 to get an access token. Set `MICROSOFT_GRAPH_ACCESS_TOKEN` (shared with Microsoft Bookings adapter).\n\n**Authentication**: `Authorization: Bearer ${MICROSOFT_GRAPH_ACCESS_TOKEN}`.\n\n**Teams hierarchy**: Team → Channel → Message (+ replies). Each Team is also a Microsoft 365 Group.\n\n**Message content**: `body.contentType` is `text` or `html`. `body.content` is the message. Mentions go in `mentions[]` with `mentionText` matching the user's display name and `mentioned.user.id` set to their Azure AD user ID.\n\n**Pagination**: standard OData `@odata.nextLink` cursor.\n\n**Rate limits**: Graph throttling — ~10k req per 10 min per app per user. Teams-specific endpoints often have lower limits. On 429 honor Retry-After.\n\n**Out of scope here**: chat (DMs) beyond list, calls/meetings, apps installed in teams, shifts, planner.", + "region": "intl", + "category": "messaging", + "icon": "microsoft-teams", + "docsUrl": "https://learn.microsoft.com/en-us/graph/api/resources/teams-api-overview", + "requiredEnvVars": ["MICROSOFT_GRAPH_ACCESS_TOKEN"], + "connector": { + "name": "Microsoft Graph (Teams)", + "type": "REST", + "baseUrl": "https://graph.microsoft.com/v1.0", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{MICROSOFT_GRAPH_ACCESS_TOKEN}}" + } + }, + "tools": [ + { + "name": "microsoft_teams_list_joined_teams", + "description": "List teams the authenticated user is a member of (for delegated auth) — returns id, displayName, description, internalId.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/me/joinedTeams" } + }, + { + "name": "microsoft_teams_get_team", + "description": "Get a team by ID.", + "parameters": { + "type": "object", + "properties": { + "teamId": { "type": "string", "description": "Team ID (GUID)." } + }, + "required": ["teamId"] + }, + "endpointMapping": { "method": "GET", "path": "/teams/{teamId}" } + }, + { + "name": "microsoft_teams_list_channels", + "description": "List channels in a team. Each channel has id, displayName, description, membershipType (standard/private/shared).", + "parameters": { + "type": "object", + "properties": { + "teamId": { "type": "string", "description": "Team ID." } + }, + "required": ["teamId"] + }, + "endpointMapping": { "method": "GET", "path": "/teams/{teamId}/channels" } + }, + { + "name": "microsoft_teams_get_channel", + "description": "Get a channel by ID.", + "parameters": { + "type": "object", + "properties": { + "teamId": { "type": "string", "description": "Team ID." }, + "channelId": { "type": "string", "description": "Channel ID." } + }, + "required": ["teamId", "channelId"] + }, + "endpointMapping": { "method": "GET", "path": "/teams/{teamId}/channels/{channelId}" } + }, + { + "name": "microsoft_teams_list_channel_messages", + "description": "List recent messages in a channel.", + "parameters": { + "type": "object", + "properties": { + "teamId": { "type": "string", "description": "Team ID." }, + "channelId": { "type": "string", "description": "Channel ID." }, + "$top": { "type": "integer", "description": "Max per page (default 20, max 50)." } + }, + "required": ["teamId", "channelId"] + }, + "endpointMapping": { + "method": "GET", + "path": "/teams/{teamId}/channels/{channelId}/messages", + "queryParams": { "$top": "$$top" } + } + }, + { + "name": "microsoft_teams_send_channel_message", + "description": "Send a message to a channel. body.contentType 'text' (plain) or 'html'. Mentions array required for @-mentions to fire notifications.", + "parameters": { + "type": "object", + "properties": { + "teamId": { "type": "string", "description": "Team ID." }, + "channelId": { "type": "string", "description": "Channel ID." }, + "body": { + "type": "object", + "description": "{contentType:'text'|'html', content:'Hello team!'}." + }, + "subject": { "type": "string", "description": "Optional message subject (shown in announcement channels)." }, + "importance": { "type": "string", "description": "normal, high, urgent." }, + "mentions": { "type": "array", "description": "[{id:0, mentionText:'Jane Doe', mentioned:{user:{id:'azure-ad-user-id', displayName:'Jane Doe', userIdentityType:'aadUser'}}}]." } + }, + "required": ["teamId", "channelId", "body"] + }, + "endpointMapping": { + "method": "POST", + "path": "/teams/{teamId}/channels/{channelId}/messages", + "bodyMapping": { + "body": "$body", + "subject": "$subject", + "importance": "$importance", + "mentions": "$mentions" + } + } + }, + { + "name": "microsoft_teams_reply_to_message", + "description": "Post a reply in a thread on a channel message.", + "parameters": { + "type": "object", + "properties": { + "teamId": { "type": "string", "description": "Team ID." }, + "channelId": { "type": "string", "description": "Channel ID." }, + "messageId": { "type": "string", "description": "Parent message ID." }, + "body": { "type": "object", "description": "{contentType, content}." } + }, + "required": ["teamId", "channelId", "messageId", "body"] + }, + "endpointMapping": { + "method": "POST", + "path": "/teams/{teamId}/channels/{channelId}/messages/{messageId}/replies", + "bodyMapping": { "body": "$body" } + } + }, + { + "name": "microsoft_teams_list_channel_members", + "description": "List members of a channel.", + "parameters": { + "type": "object", + "properties": { + "teamId": { "type": "string", "description": "Team ID." }, + "channelId": { "type": "string", "description": "Channel ID." } + }, + "required": ["teamId", "channelId"] + }, + "endpointMapping": { "method": "GET", "path": "/teams/{teamId}/channels/{channelId}/members" } + }, + { + "name": "microsoft_teams_list_team_members", + "description": "List members of a team. Each member has id, displayName, email, roles[].", + "parameters": { + "type": "object", + "properties": { + "teamId": { "type": "string", "description": "Team ID." } + }, + "required": ["teamId"] + }, + "endpointMapping": { "method": "GET", "path": "/teams/{teamId}/members" } + } + ] +} diff --git a/packages/backend/src/adapters/intl/microsoft-teams.live.spec.ts b/packages/backend/src/adapters/intl/microsoft-teams.live.spec.ts new file mode 100644 index 0000000..a144376 --- /dev/null +++ b/packages/backend/src/adapters/intl/microsoft-teams.live.spec.ts @@ -0,0 +1,6 @@ +import * as adapter from './microsoft-teams.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string } }; +describe('microsoft-teams adapter — static spec conformance', () => { + it('graph.microsoft.com/v1.0', () => expect(a.connector.baseUrl).toBe('https://graph.microsoft.com/v1.0')); + it('Bearer auth', () => expect(a.connector.authType).toBe('BEARER_TOKEN')); +}); From 03175195523357abfccb82edead570a3f7aae39f Mon Sep 17 00:00:00 2001 From: Matteo Date: Wed, 20 May 2026 13:41:48 +0200 Subject: [PATCH 19/19] fix(connectors): slab GraphQL spec + statsig CodeQL false positive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - slab.json: GRAPHQL connectors put the operation type in endpointMapping.method ('query'|'mutation') and the GraphQL document in .path — NOT POST + body. The catalog.spec.ts test enforces this distinction (VALID_GRAPHQL_METHODS) and CI was failing on 6 slab tools. Rewrote to follow the existing Sorare pattern with variables under bodyMapping.variables. - statsig.live.spec.ts: replaced substring URL match with new URL() hostname comparison — CodeQL flagged the startsWith pattern as incomplete-url-sanitization (false positive in test code but it was blocking the PR merge). Stricter check is also more correct. --- packages/backend/src/adapters/intl/slab.json | 32 +++++++------------ .../src/adapters/intl/statsig.live.spec.ts | 11 ++++++- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/packages/backend/src/adapters/intl/slab.json b/packages/backend/src/adapters/intl/slab.json index cfa98fd..c3a3f61 100644 --- a/packages/backend/src/adapters/intl/slab.json +++ b/packages/backend/src/adapters/intl/slab.json @@ -23,11 +23,8 @@ "description": "Return the user the token belongs to (id, name, email, organization). Use the auto-injected slab_graphql_query for more complex selections.", "parameters": { "type": "object", "properties": {} }, "endpointMapping": { - "method": "POST", - "path": "", - "bodyMapping": { - "query": "{ me { id name email organization { id name } } }" - } + "method": "query", + "path": "{ me { id name email organization { id name } } }" } }, { @@ -41,10 +38,9 @@ "required": ["id"] }, "endpointMapping": { - "method": "POST", - "path": "", + "method": "query", + "path": "query GetPost($id: ID!) { post(id: $id) { id title content(format: MARKDOWN) updatedAt owner { name } topics { id name } } }", "bodyMapping": { - "query": "query GetPost($id: ID!) { post(id: $id) { id title content(format: MARKDOWN) updatedAt owner { name } topics { id name } } }", "variables": { "id": "$id" } } } @@ -61,10 +57,9 @@ "required": ["query"] }, "endpointMapping": { - "method": "POST", - "path": "", + "method": "query", + "path": "query SearchPosts($q: String!, $first: Int) { search(query: $q, first: $first) { edges { node { ... on Post { id title updatedAt owner { name } } } } } }", "bodyMapping": { - "query": "query SearchPosts($q: String!, $first: Int) { search(query: $q, first: $first) { edges { node { ... on Post { id title updatedAt owner { name } } } } } }", "variables": { "q": "$query", "first": "$first" } } } @@ -79,10 +74,9 @@ } }, "endpointMapping": { - "method": "POST", - "path": "", + "method": "query", + "path": "query ListTopics($first: Int) { topics(first: $first) { edges { node { id name description parent { id name } } } } }", "bodyMapping": { - "query": "query ListTopics($first: Int) { topics(first: $first) { edges { node { id name description parent { id name } } } } }", "variables": { "first": "$first" } } } @@ -99,10 +93,9 @@ "required": ["topicId"] }, "endpointMapping": { - "method": "POST", - "path": "", + "method": "query", + "path": "query TopicPosts($id: ID!, $first: Int) { topic(id: $id) { id name posts(first: $first) { edges { node { id title updatedAt } } } } }", "bodyMapping": { - "query": "query TopicPosts($id: ID!, $first: Int) { topic(id: $id) { id name posts(first: $first) { edges { node { id title updatedAt } } } } }", "variables": { "id": "$topicId", "first": "$first" } } } @@ -120,10 +113,9 @@ "required": ["title", "content"] }, "endpointMapping": { - "method": "POST", - "path": "", + "method": "mutation", + "path": "mutation CreatePost($title: String!, $content: String!, $topics: [ID!]) { postCreate(title: $title, content: $content, format: MARKDOWN, topicIds: $topics) { id title } }", "bodyMapping": { - "query": "mutation CreatePost($title: String!, $content: String!, $topics: [ID!]) { postCreate(title: $title, content: $content, format: MARKDOWN, topicIds: $topics) { id title } }", "variables": { "title": "$title", "content": "$content", diff --git a/packages/backend/src/adapters/intl/statsig.live.spec.ts b/packages/backend/src/adapters/intl/statsig.live.spec.ts index 108ec97..cecb374 100644 --- a/packages/backend/src/adapters/intl/statsig.live.spec.ts +++ b/packages/backend/src/adapters/intl/statsig.live.spec.ts @@ -6,7 +6,16 @@ const a = adapter as unknown as { describe('statsig adapter — static spec conformance', () => { it('SDK base URL is api.statsig.com/v1', () => expect(a.connector.baseUrl).toBe('https://api.statsig.com/v1')); it('Console tools use absolute URLs to statsigapi.net (different host)', () => { - const consoleTools = a.tools.filter((t) => t.endpointMapping.path.startsWith('https://statsigapi.net')); + // Use URL parsing + exact-host comparison (not substring includes) so the + // test cannot match URLs like https://statsigapi.net.evil.example/... + const consoleTools = a.tools.filter((t) => { + try { + const u = new URL(t.endpointMapping.path); + return u.hostname === 'statsigapi.net'; + } catch { + return false; + } + }); expect(consoleTools.length).toBeGreaterThan(0); for (const t of consoleTools) { expect(t.endpointMapping.path).toMatch(/^https:\/\/statsigapi\.net\/console\/v1\//);