From 7d71c4a6d2b42dacb35ddd3b58e4b014ed119d4f Mon Sep 17 00:00:00 2001 From: Matteo Date: Wed, 20 May 2026 20:41:22 +0200 Subject: [PATCH 1/2] =?UTF-8?q?connectors:=20add=20batch=202a=20=E2=80=94?= =?UTF-8?q?=2015=20greenfield=20SaaS=20adapters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the catalog from 119 to 134 adapters. Each comes with rich instructions, per-tool descriptions, parameter docs and a static live spec asserting baseUrl + auth shape. Categories covered: data: openweather, coingecko, newsapi, hackernews monitoring: datadog, new-relic, bugsnag, uptime-robot time-tracking: harvest, toggl-track, clockify social: bluesky, mastodon support: gorgias, freshservice Notes: - coingecko uses optional x-cg-demo-api-key (works anon for the public tier, raises rate limit when set). - datadog requires the dual DD-API-KEY + DD-APPLICATION-KEY pair via the existing API_KEY extraHeaders extension. - new-relic wraps NerdGraph (GraphQL) NRQL queries through a REST POST to /graphql plus the legacy REST deployments endpoint. - uptime-robot uses QUERY_AUTH on api_key + format=json so each tool body stays clean form-encoded params (the API accepts auth in URL). - harvest, gorgias, freshservice, mastodon use {{}} placeholders in baseUrl/headers so each tenant injects subdomain/account-id/instance at import time. - toggl-track uses the documented Basic auth pair (token, "api_token"). - bluesky exposes session create + refresh so the JWT lifecycle is client-managed; subsequent tools take the access_jwt as a tool param. All adapters validate via scripts/validate-adapters.mjs and 15/15 new live specs pass (35 tests). Catalog regenerated. --- packages/backend/src/adapters/catalog.ts | 30 ++ .../backend/src/adapters/intl/bluesky.json | 202 ++++++++++++++ .../src/adapters/intl/bluesky.live.spec.ts | 16 ++ .../backend/src/adapters/intl/bugsnag.json | 166 +++++++++++ .../src/adapters/intl/bugsnag.live.spec.ts | 17 ++ .../backend/src/adapters/intl/clockify.json | 238 ++++++++++++++++ .../src/adapters/intl/clockify.live.spec.ts | 12 + .../backend/src/adapters/intl/coingecko.json | 168 +++++++++++ .../src/adapters/intl/coingecko.live.spec.ts | 12 + .../backend/src/adapters/intl/datadog.json | 192 +++++++++++++ .../src/adapters/intl/datadog.live.spec.ts | 17 ++ .../src/adapters/intl/freshservice.json | 262 ++++++++++++++++++ .../adapters/intl/freshservice.live.spec.ts | 12 + .../backend/src/adapters/intl/gorgias.json | 253 +++++++++++++++++ .../src/adapters/intl/gorgias.live.spec.ts | 8 + .../backend/src/adapters/intl/hackernews.json | 80 ++++++ .../src/adapters/intl/hackernews.live.spec.ts | 7 + .../backend/src/adapters/intl/harvest.json | 260 +++++++++++++++++ .../src/adapters/intl/harvest.live.spec.ts | 13 + .../backend/src/adapters/intl/mastodon.json | 199 +++++++++++++ .../src/adapters/intl/mastodon.live.spec.ts | 8 + .../backend/src/adapters/intl/new-relic.json | 159 +++++++++++ .../src/adapters/intl/new-relic.live.spec.ts | 12 + .../backend/src/adapters/intl/newsapi.json | 104 +++++++ .../src/adapters/intl/newsapi.live.spec.ts | 12 + .../src/adapters/intl/openweather.json | 130 +++++++++ .../adapters/intl/openweather.live.spec.ts | 19 ++ .../src/adapters/intl/toggl-track.json | 175 ++++++++++++ .../adapters/intl/toggl-track.live.spec.ts | 12 + .../src/adapters/intl/uptime-robot.json | 194 +++++++++++++ .../adapters/intl/uptime-robot.live.spec.ts | 17 ++ 31 files changed, 3006 insertions(+) create mode 100644 packages/backend/src/adapters/intl/bluesky.json create mode 100644 packages/backend/src/adapters/intl/bluesky.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/bugsnag.json create mode 100644 packages/backend/src/adapters/intl/bugsnag.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/clockify.json create mode 100644 packages/backend/src/adapters/intl/clockify.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/coingecko.json create mode 100644 packages/backend/src/adapters/intl/coingecko.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/datadog.json create mode 100644 packages/backend/src/adapters/intl/datadog.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/freshservice.json create mode 100644 packages/backend/src/adapters/intl/freshservice.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/gorgias.json create mode 100644 packages/backend/src/adapters/intl/gorgias.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/hackernews.json create mode 100644 packages/backend/src/adapters/intl/hackernews.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/harvest.json create mode 100644 packages/backend/src/adapters/intl/harvest.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/mastodon.json create mode 100644 packages/backend/src/adapters/intl/mastodon.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/new-relic.json create mode 100644 packages/backend/src/adapters/intl/new-relic.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/newsapi.json create mode 100644 packages/backend/src/adapters/intl/newsapi.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/openweather.json create mode 100644 packages/backend/src/adapters/intl/openweather.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/toggl-track.json create mode 100644 packages/backend/src/adapters/intl/toggl-track.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/uptime-robot.json create mode 100644 packages/backend/src/adapters/intl/uptime-robot.live.spec.ts diff --git a/packages/backend/src/adapters/catalog.ts b/packages/backend/src/adapters/catalog.ts index ca22800..97f0086 100644 --- a/packages/backend/src/adapters/catalog.ts +++ b/packages/backend/src/adapters/catalog.ts @@ -40,16 +40,21 @@ 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'; +import * as bluesky from './intl/bluesky.json'; import * as brevo from './intl/brevo.json'; +import * as bugsnag from './intl/bugsnag.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 clockify from './intl/clockify.json'; import * as close from './intl/close.json'; import * as coda from './intl/coda.json'; +import * as coingecko from './intl/coingecko.json'; import * as convertkit from './intl/convertkit.json'; import * as copper from './intl/copper.json'; import * as crisp from './intl/crisp.json'; +import * as datadog from './intl/datadog.json'; import * as discordBot from './intl/discord-bot.json'; import * as drip from './intl/drip.json'; import * as dropboxSign from './intl/dropbox-sign.json'; @@ -59,9 +64,13 @@ 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 freshservice from './intl/freshservice.json'; import * as front from './intl/front.json'; import * as ghost from './intl/ghost.json'; import * as gitbook from './intl/gitbook.json'; +import * as gorgias from './intl/gorgias.json'; +import * as hackernews from './intl/hackernews.json'; +import * as harvest from './intl/harvest.json'; import * as heap from './intl/heap.json'; import * as height from './intl/height.json'; import * as helpScout from './intl/help-scout.json'; @@ -78,6 +87,7 @@ 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 mastodon from './intl/mastodon.json'; import * as medium from './intl/medium.json'; import * as messagebird from './intl/messagebird.json'; import * as microsoftBookings from './intl/microsoft-bookings.json'; @@ -85,7 +95,10 @@ 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'; +import * as newRelic from './intl/new-relic.json'; +import * as newsapi from './intl/newsapi.json'; import * as nominatim from './intl/nominatim.json'; +import * as openweather from './intl/openweather.json'; import * as outreach from './intl/outreach.json'; import * as pandadoc from './intl/pandadoc.json'; import * as pipedrive from './intl/pipedrive.json'; @@ -105,8 +118,10 @@ 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 togglTrack from './intl/toggl-track.json'; import * as trello from './intl/trello.json'; import * as typeform from './intl/typeform.json'; +import * as uptimeRobot from './intl/uptime-robot.json'; import * as vercelAnalytics from './intl/vercel-analytics.json'; import * as whatsappBusiness from './intl/whatsapp-business.json'; import * as woocommerce from './intl/woocommerce.json'; @@ -231,16 +246,21 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ basecamp as unknown as AdapterDefinition, beehiiv as unknown as AdapterDefinition, bigcommerce as unknown as AdapterDefinition, + bluesky as unknown as AdapterDefinition, brevo as unknown as AdapterDefinition, + bugsnag as unknown as AdapterDefinition, calendly as unknown as AdapterDefinition, chargebee as unknown as AdapterDefinition, clearbit as unknown as AdapterDefinition, clickup as unknown as AdapterDefinition, + clockify as unknown as AdapterDefinition, close as unknown as AdapterDefinition, coda as unknown as AdapterDefinition, + coingecko as unknown as AdapterDefinition, convertkit as unknown as AdapterDefinition, copper as unknown as AdapterDefinition, crisp as unknown as AdapterDefinition, + datadog as unknown as AdapterDefinition, discordBot as unknown as AdapterDefinition, drip as unknown as AdapterDefinition, dropboxSign as unknown as AdapterDefinition, @@ -250,9 +270,13 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ fillout as unknown as AdapterDefinition, folk as unknown as AdapterDefinition, freshdesk as unknown as AdapterDefinition, + freshservice as unknown as AdapterDefinition, front as unknown as AdapterDefinition, ghost as unknown as AdapterDefinition, gitbook as unknown as AdapterDefinition, + gorgias as unknown as AdapterDefinition, + hackernews as unknown as AdapterDefinition, + harvest as unknown as AdapterDefinition, heap as unknown as AdapterDefinition, height as unknown as AdapterDefinition, helpScout as unknown as AdapterDefinition, @@ -269,6 +293,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ mailchimp as unknown as AdapterDefinition, mailshake as unknown as AdapterDefinition, mapbox as unknown as AdapterDefinition, + mastodon as unknown as AdapterDefinition, medium as unknown as AdapterDefinition, messagebird as unknown as AdapterDefinition, microsoftBookings as unknown as AdapterDefinition, @@ -276,7 +301,10 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ mintlify as unknown as AdapterDefinition, mollie as unknown as AdapterDefinition, neverbounce as unknown as AdapterDefinition, + newRelic as unknown as AdapterDefinition, + newsapi as unknown as AdapterDefinition, nominatim as unknown as AdapterDefinition, + openweather as unknown as AdapterDefinition, outreach as unknown as AdapterDefinition, pandadoc as unknown as AdapterDefinition, pipedrive as unknown as AdapterDefinition, @@ -296,8 +324,10 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ telegramBot as unknown as AdapterDefinition, ticktick as unknown as AdapterDefinition, todoist as unknown as AdapterDefinition, + togglTrack as unknown as AdapterDefinition, trello as unknown as AdapterDefinition, typeform as unknown as AdapterDefinition, + uptimeRobot as unknown as AdapterDefinition, vercelAnalytics as unknown as AdapterDefinition, whatsappBusiness as unknown as AdapterDefinition, woocommerce as unknown as AdapterDefinition, diff --git a/packages/backend/src/adapters/intl/bluesky.json b/packages/backend/src/adapters/intl/bluesky.json new file mode 100644 index 0000000..544cb7c --- /dev/null +++ b/packages/backend/src/adapters/intl/bluesky.json @@ -0,0 +1,202 @@ +{ + "slug": "bluesky", + "name": "Bluesky", + "description": "Read & post on Bluesky (the AT Protocol social network) from any AI agent. 9 tools, app-password auth.", + "instructions": "This connector wraps the Bluesky AT Protocol XRPC API (bsky.social).\n\n**Setup**:\n1. In the Bluesky app or web client → **Settings → Privacy and Security → App Passwords → Add App Password**.\n2. Name it (e.g. 'mcp') and copy the generated `xxxx-xxxx-xxxx-xxxx` password. App passwords scope down what they can do — never use your account password.\n3. Set `BLUESKY_HANDLE` (e.g. `you.bsky.social`) and `BLUESKY_APP_PASSWORD`.\n\n**Two-step auth**: AT Protocol uses a session model. You can't just send the app password on every call — you must first POST to `/xrpc/com.atproto.server.createSession` with `{identifier, password}` and use the returned `accessJwt` as the Bearer token. The `bluesky_create_session` tool does step 1; subsequent tools use the JWT.\n\nFor production use the access JWT should be cached (it lasts ~2 hours) and refreshed with `refreshJwt` via `/xrpc/com.atproto.server.refreshSession`. This adapter exposes session create + refresh as tools so the caller can manage lifecycle.\n\n**DIDs vs handles**: Bluesky internally uses DIDs (`did:plc:abcd...`). Public APIs accept either DID or handle (`you.bsky.social`); use `bluesky_resolve_handle` to convert.\n\n**Post records**: posts are AT Protocol records of type `app.bsky.feed.post`. They have `text` (max 300 graphemes), `createdAt` (RFC3339), `langs[]`, and optional `embed` for images/links.\n\n**Rate limits**: 5k req / hour per JWT; create-session limited to 30/5min per IP. 429 with `Retry-After`.\n\n**Out of scope here**: image upload multipart (use the public URL embed pattern instead), labelers/moderation, custom feeds CRUD, DM (separate API), the firehose.", + "region": "intl", + "category": "social", + "icon": "bluesky", + "docsUrl": "https://docs.bsky.app/", + "requiredEnvVars": ["BLUESKY_HANDLE", "BLUESKY_APP_PASSWORD"], + "connector": { + "name": "Bluesky AT Protocol", + "type": "REST", + "baseUrl": "https://bsky.social", + "authType": "NONE", + "authConfig": {} + }, + "tools": [ + { + "name": "bluesky_create_session", + "description": "Exchange handle + app password for an access JWT (lasts ~2h) and refresh JWT. Returns `accessJwt`, `refreshJwt`, `handle`, `did`. Call this once at the start of a session, then pass `accessJwt` to other tools that need it.", + "parameters": { + "type": "object", + "properties": { + "identifier": { "type": "string", "description": "Handle (e.g. you.bsky.social) or email." }, + "password": { "type": "string", "description": "App password." } + }, + "required": ["identifier", "password"] + }, + "endpointMapping": { + "method": "POST", + "path": "/xrpc/com.atproto.server.createSession", + "bodyMapping": { "identifier": "$identifier", "password": "$password" } + } + }, + { + "name": "bluesky_refresh_session", + "description": "Refresh an access JWT using the refresh JWT.", + "parameters": { + "type": "object", + "properties": { + "refresh_jwt": { "type": "string", "description": "refreshJwt from create_session." } + }, + "required": ["refresh_jwt"] + }, + "endpointMapping": { + "method": "POST", + "path": "/xrpc/com.atproto.server.refreshSession", + "headers": { "Authorization": "Bearer ${refresh_jwt}" } + } + }, + { + "name": "bluesky_resolve_handle", + "description": "Resolve a Bluesky handle (e.g. `bsky.app`) to its DID. Public, no auth needed.", + "parameters": { + "type": "object", + "properties": { + "handle": { "type": "string", "description": "Handle (no @)." } + }, + "required": ["handle"] + }, + "endpointMapping": { + "method": "GET", + "path": "/xrpc/com.atproto.identity.resolveHandle", + "queryParams": { "handle": "$handle" } + } + }, + { + "name": "bluesky_get_profile", + "description": "Get a user's public profile: handle, did, displayName, description, followers, follows, posts count, avatar URL.", + "parameters": { + "type": "object", + "properties": { + "actor": { "type": "string", "description": "Handle or DID." }, + "access_jwt": { "type": "string", "description": "Access JWT from create_session." } + }, + "required": ["actor", "access_jwt"] + }, + "endpointMapping": { + "method": "GET", + "path": "/xrpc/app.bsky.actor.getProfile", + "queryParams": { "actor": "$actor" }, + "headers": { "Authorization": "Bearer ${access_jwt}" } + } + }, + { + "name": "bluesky_get_timeline", + "description": "Get the authenticated user's home timeline (chronological feed of follows). Paginated.", + "parameters": { + "type": "object", + "properties": { + "access_jwt": { "type": "string", "description": "Access JWT." }, + "limit": { "type": "integer", "description": "Max posts (1-100)." }, + "cursor": { "type": "string", "description": "Pagination cursor from previous response." } + }, + "required": ["access_jwt"] + }, + "endpointMapping": { + "method": "GET", + "path": "/xrpc/app.bsky.feed.getTimeline", + "queryParams": { "limit": "$limit", "cursor": "$cursor" }, + "headers": { "Authorization": "Bearer ${access_jwt}" } + } + }, + { + "name": "bluesky_get_author_feed", + "description": "Get posts by a specific user (their public feed).", + "parameters": { + "type": "object", + "properties": { + "actor": { "type": "string", "description": "Handle or DID." }, + "access_jwt": { "type": "string", "description": "Access JWT." }, + "limit": { "type": "integer", "description": "Max posts (1-100)." }, + "cursor": { "type": "string", "description": "Pagination cursor." }, + "filter": { "type": "string", "description": "'posts_with_replies', 'posts_no_replies', 'posts_with_media', 'posts_and_author_threads'." } + }, + "required": ["actor", "access_jwt"] + }, + "endpointMapping": { + "method": "GET", + "path": "/xrpc/app.bsky.feed.getAuthorFeed", + "queryParams": { "actor": "$actor", "limit": "$limit", "cursor": "$cursor", "filter": "$filter" }, + "headers": { "Authorization": "Bearer ${access_jwt}" } + } + }, + { + "name": "bluesky_search_posts", + "description": "Full-text search across public posts.", + "parameters": { + "type": "object", + "properties": { + "q": { "type": "string", "description": "Search query." }, + "access_jwt": { "type": "string", "description": "Access JWT." }, + "limit": { "type": "integer", "description": "Max results (1-100)." }, + "cursor": { "type": "string", "description": "Pagination cursor." }, + "lang": { "type": "string", "description": "ISO language filter." }, + "sort": { "type": "string", "description": "'top' or 'latest'." } + }, + "required": ["q", "access_jwt"] + }, + "endpointMapping": { + "method": "GET", + "path": "/xrpc/app.bsky.feed.searchPosts", + "queryParams": { "q": "$q", "limit": "$limit", "cursor": "$cursor", "lang": "$lang", "sort": "$sort" }, + "headers": { "Authorization": "Bearer ${access_jwt}" } + } + }, + { + "name": "bluesky_create_post", + "description": "Post a new skeet. `text` is required (max 300 graphemes). Returns the post's URI + CID.", + "parameters": { + "type": "object", + "properties": { + "access_jwt": { "type": "string", "description": "Access JWT." }, + "repo": { "type": "string", "description": "Your DID (from create_session response)." }, + "text": { "type": "string", "description": "Post text, max 300 graphemes." }, + "created_at": { "type": "string", "description": "RFC3339 datetime (defaults to now if omitted)." }, + "langs": { "type": "array", "description": "ISO 639-1 language codes, e.g. ['en']." } + }, + "required": ["access_jwt", "repo", "text", "created_at"] + }, + "endpointMapping": { + "method": "POST", + "path": "/xrpc/com.atproto.repo.createRecord", + "bodyMapping": { + "repo": "$repo", + "collection": "app.bsky.feed.post", + "record": { + "$type": "app.bsky.feed.post", + "text": "$text", + "createdAt": "$created_at", + "langs": "$langs" + } + }, + "headers": { "Authorization": "Bearer ${access_jwt}" } + } + }, + { + "name": "bluesky_delete_post", + "description": "Delete one of your own posts by its rkey (record key, the last segment of the at:// URI).", + "parameters": { + "type": "object", + "properties": { + "access_jwt": { "type": "string", "description": "Access JWT." }, + "repo": { "type": "string", "description": "Your DID." }, + "rkey": { "type": "string", "description": "Record key — the last segment of the post's at:// URI." } + }, + "required": ["access_jwt", "repo", "rkey"] + }, + "endpointMapping": { + "method": "POST", + "path": "/xrpc/com.atproto.repo.deleteRecord", + "bodyMapping": { + "repo": "$repo", + "collection": "app.bsky.feed.post", + "rkey": "$rkey" + }, + "headers": { "Authorization": "Bearer ${access_jwt}" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/bluesky.live.spec.ts b/packages/backend/src/adapters/intl/bluesky.live.spec.ts new file mode 100644 index 0000000..35c50e6 --- /dev/null +++ b/packages/backend/src/adapters/intl/bluesky.live.spec.ts @@ -0,0 +1,16 @@ +import * as adapter from './bluesky.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string }; + tools: Array<{ name: string; endpointMapping: { path: string } }>; +}; +describe('bluesky adapter — static spec conformance', () => { + it('bsky.social base URL', () => expect(a.connector.baseUrl).toBe('https://bsky.social')); + it('uses XRPC path namespace for every tool', () => { + for (const t of a.tools) expect(t.endpointMapping.path).toMatch(/^\/xrpc\//); + }); + it('exposes session create + refresh', () => { + const names = a.tools.map((t) => t.name); + expect(names).toContain('bluesky_create_session'); + expect(names).toContain('bluesky_refresh_session'); + }); +}); diff --git a/packages/backend/src/adapters/intl/bugsnag.json b/packages/backend/src/adapters/intl/bugsnag.json new file mode 100644 index 0000000..5c0d914 --- /dev/null +++ b/packages/backend/src/adapters/intl/bugsnag.json @@ -0,0 +1,166 @@ +{ + "slug": "bugsnag", + "name": "Bugsnag", + "description": "Read & triage Bugsnag (now SmartBear Insight Hub) errors, projects, organizations, releases, pivots from any AI agent. 9 tools, token auth.", + "instructions": "This connector wraps the Bugsnag Data Access API (api.bugsnag.com).\n\n**Setup**:\n1. Log into https://app.bugsnag.com → bottom-left profile → **My Account → Personal Auth Tokens** → Generate.\n2. Pick the scopes you need (`project errors`, `project events`, `project release groups`, `project comments`, `project pivots`).\n3. Set `BUGSNAG_AUTH_TOKEN`.\n\n**Authentication**: every request sends `Authorization: token ${BUGSNAG_AUTH_TOKEN}` AND `X-Version: 2`. The adapter wires both for you.\n\n**Hierarchy**: `Organization → Project → Error → Event(s)`. An \"Error\" is the deduplicated group; \"Events\" are the individual occurrences with stack trace + breadcrumbs + user. To get useful debug info you usually want the latest event of an error.\n\n**Bootstrapping the IDs**:\n1. `bugsnag_list_organizations` → grab `org_id`.\n2. `bugsnag_list_projects` → grab `project_id`.\n3. `bugsnag_list_errors` → grab `error_id`.\n4. `bugsnag_get_event` → full stack trace.\n\n**Filter syntax**: error/event list endpoints accept `filters[field][type][operator]=value` repeated. Common: `filters[event.since][][type]=eq&filters[event.since][][value]=7d` (last 7 days), `filters[error.status][][type]=eq&filters[error.status][][value]=open`. Stick to a few common patterns in tool args — full grammar lives at https://bugsnagapiv2.docs.apiary.io/.\n\n**Rate limits**: 60 req/min per token, 5k/hour. 429 with `Retry-After` header.\n\n**Out of scope here**: project CRUD, account billing, integrations setup, deploys via REST (use the dedicated build reporter SDK instead).", + "region": "intl", + "category": "monitoring", + "icon": "bugsnag", + "docsUrl": "https://bugsnagapiv2.docs.apiary.io/", + "requiredEnvVars": ["BUGSNAG_AUTH_TOKEN"], + "connector": { + "name": "Bugsnag Data Access API v2", + "type": "REST", + "baseUrl": "https://api.bugsnag.com", + "authType": "API_KEY", + "authConfig": { + "headerName": "Authorization", + "apiKey": "token {{BUGSNAG_AUTH_TOKEN}}", + "extraHeaders": { + "X-Version": "2" + } + } + }, + "tools": [ + { + "name": "bugsnag_current_user", + "description": "Return the user account the token belongs to: id, name, email, created_at.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/user" } + }, + { + "name": "bugsnag_list_organizations", + "description": "List orgs the user belongs to. Each has id, name, slug, billing/plan info, creator. Most users have exactly one.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/user/organizations" } + }, + { + "name": "bugsnag_list_projects", + "description": "List projects inside an org. Returns id, slug, name, api_key, type (e.g. js, node, ruby), open_error_count, urls.", + "parameters": { + "type": "object", + "properties": { + "organization_id": { "type": "string", "description": "Org ID from bugsnag_list_organizations." }, + "per_page": { "type": "integer", "description": "Per page (max 100)." }, + "q": { "type": "string", "description": "Optional name filter." } + }, + "required": ["organization_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/organizations/{organization_id}/projects", + "queryParams": { "per_page": "$per_page", "q": "$q" } + } + }, + { + "name": "bugsnag_list_errors", + "description": "List errors in a project. Errors are deduplicated stack-trace groups. Each has id, error_class, message, context, status (open/fixed/snoozed/ignored), severity, events count, users count, first_seen, last_seen.", + "parameters": { + "type": "object", + "properties": { + "project_id": { "type": "string", "description": "Project ID." }, + "sort": { "type": "string", "description": "first_seen, last_seen, users, events, unsorted." }, + "direction": { "type": "string", "description": "asc / desc." }, + "per_page": { "type": "integer", "description": "Per page (max 30)." }, + "filters_status": { "type": "string", "description": "Shortcut filter by status: open, fixed, snoozed, ignored. Internally adds filters[error.status]." }, + "filters_since": { "type": "string", "description": "Shortcut filter by time window, e.g. '7d', '24h', '1m'. Internally adds filters[event.since]." } + }, + "required": ["project_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/projects/{project_id}/errors", + "queryParams": { + "sort": "$sort", + "direction": "$direction", + "per_page": "$per_page", + "filters[error.status][][type]": "eq", + "filters[error.status][][value]": "$filters_status", + "filters[event.since][][type]": "eq", + "filters[event.since][][value]": "$filters_since" + } + } + }, + { + "name": "bugsnag_get_error", + "description": "Get a single error by ID with its full metadata, severity, status, top stack frame.", + "parameters": { + "type": "object", + "properties": { + "error_id": { "type": "string", "description": "Error ID." } + }, + "required": ["error_id"] + }, + "endpointMapping": { "method": "GET", "path": "/errors/{error_id}" } + }, + { + "name": "bugsnag_list_events", + "description": "List events (individual occurrences) for an error. Each event has the full stack, breadcrumbs, user context, device info.", + "parameters": { + "type": "object", + "properties": { + "error_id": { "type": "string", "description": "Error ID." }, + "per_page": { "type": "integer", "description": "Per page (max 30)." }, + "full_reports": { "type": "boolean", "description": "If true, include full stack + breadcrumbs in each event (slower)." } + }, + "required": ["error_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/errors/{error_id}/events", + "queryParams": { "per_page": "$per_page", "full_reports": "$full_reports" } + } + }, + { + "name": "bugsnag_get_event", + "description": "Get a single event by ID — full stack trace, breadcrumbs, user, app, device, request, custom metadata. This is the most useful tool for debugging.", + "parameters": { + "type": "object", + "properties": { + "project_id": { "type": "string", "description": "Project ID." }, + "event_id": { "type": "string", "description": "Event ID." } + }, + "required": ["project_id", "event_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/projects/{project_id}/events/{event_id}" + } + }, + { + "name": "bugsnag_update_error", + "description": "Update the status/severity of an error: fix, ignore, snooze, reopen, change severity.", + "parameters": { + "type": "object", + "properties": { + "error_id": { "type": "string", "description": "Error ID." }, + "operation": { "type": "string", "description": "fix, snooze, ignore, open, override_severity." }, + "severity": { "type": "string", "description": "If operation=override_severity: error, warning, info." } + }, + "required": ["error_id", "operation"] + }, + "endpointMapping": { + "method": "PATCH", + "path": "/errors/{error_id}", + "bodyMapping": { "operation": "$operation", "severity": "$severity" } + } + }, + { + "name": "bugsnag_list_releases", + "description": "List releases for a project. Useful for cross-referencing an error spike with a deploy.", + "parameters": { + "type": "object", + "properties": { + "project_id": { "type": "string", "description": "Project ID." }, + "per_page": { "type": "integer", "description": "Per page (max 100)." } + }, + "required": ["project_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/projects/{project_id}/releases", + "queryParams": { "per_page": "$per_page" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/bugsnag.live.spec.ts b/packages/backend/src/adapters/intl/bugsnag.live.spec.ts new file mode 100644 index 0000000..872121f --- /dev/null +++ b/packages/backend/src/adapters/intl/bugsnag.live.spec.ts @@ -0,0 +1,17 @@ +import * as adapter from './bugsnag.json'; +const a = adapter as unknown as { + connector: { + baseUrl: string; + authType: string; + authConfig: { headerName: string; extraHeaders: Record }; + }; +}; +describe('bugsnag adapter — static spec conformance', () => { + it('api.bugsnag.com base URL', () => + expect(a.connector.baseUrl).toBe('https://api.bugsnag.com')); + it('Authorization header + X-Version: 2', () => { + expect(a.connector.authType).toBe('API_KEY'); + expect(a.connector.authConfig.headerName).toBe('Authorization'); + expect(a.connector.authConfig.extraHeaders['X-Version']).toBe('2'); + }); +}); diff --git a/packages/backend/src/adapters/intl/clockify.json b/packages/backend/src/adapters/intl/clockify.json new file mode 100644 index 0000000..60e4d1a --- /dev/null +++ b/packages/backend/src/adapters/intl/clockify.json @@ -0,0 +1,238 @@ +{ + "slug": "clockify", + "name": "Clockify", + "description": "Manage Clockify (time tracking, projects, clients, tags, reports) from any AI agent. 10 tools, X-Api-Key auth.", + "instructions": "This connector wraps the Clockify REST API v1 (api.clockify.me/api/v1).\n\n**Setup**:\n1. Sign in to https://clockify.me → bottom-left avatar → **Preferences → Advanced → API → Generate API key**.\n2. Set `CLOCKIFY_API_KEY`.\n\n**Authentication**: `X-Api-Key: ${CLOCKIFY_API_KEY}` on every request.\n\n**Workspace IDs**: Clockify is workspace-scoped. Use `clockify_get_user` first — its response contains `defaultWorkspace` and `activeWorkspace`. List workspaces with `clockify_list_workspaces`.\n\n**Time entry shape**: `start` + `end` are ISO 8601 UTC with the literal `Z` suffix (e.g. `2026-05-20T14:30:00Z`). For a running entry, OMIT `end` — the entry will be live until you call `clockify_stop_current_entry`.\n\n**Project / Task / Tag IDs**: every entity ID is a 24-char hex Mongo-style ID (e.g. `5b1e7b7b7e8d8e3a4c5d6f78`). Always look them up first via the list endpoints; user-facing names are not accepted.\n\n**Pagination**: `page` (1-based) + `page-size` (max 5000 but default 50). Some endpoints also support cursor pagination via `cursor`.\n\n**Rate limits**: 50 req/s per workspace; bursts tolerated. 429 with no Retry-After — back off 1s.\n\n**Out of scope here**: Reports endpoints (different base host `reports.api.clockify.me`, would be a sibling adapter), approval workflows, Pumble integration.", + "region": "intl", + "category": "time-tracking", + "icon": "clockify", + "docsUrl": "https://docs.clockify.me/", + "requiredEnvVars": ["CLOCKIFY_API_KEY"], + "connector": { + "name": "Clockify API v1", + "type": "REST", + "baseUrl": "https://api.clockify.me/api/v1", + "authType": "API_KEY", + "authConfig": { + "headerName": "X-Api-Key", + "apiKey": "{{CLOCKIFY_API_KEY}}" + } + }, + "tools": [ + { + "name": "clockify_get_user", + "description": "Return the authenticated user: id, email, name, activeWorkspace, defaultWorkspace, profile picture URL.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/user" } + }, + { + "name": "clockify_list_workspaces", + "description": "List workspaces the user belongs to.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/workspaces" } + }, + { + "name": "clockify_list_clients", + "description": "List clients in a workspace.", + "parameters": { + "type": "object", + "properties": { + "workspace_id": { "type": "string", "description": "Workspace ID." }, + "name": { "type": "string", "description": "Substring filter by name." }, + "archived": { "type": "boolean", "description": "Include archived." }, + "page": { "type": "integer", "description": "1-based." }, + "page_size": { "type": "integer", "description": "Default 50, max 5000." } + }, + "required": ["workspace_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/workspaces/{workspace_id}/clients", + "queryParams": { + "name": "$name", + "archived": "$archived", + "page": "$page", + "page-size": "$page_size" + } + } + }, + { + "name": "clockify_list_projects", + "description": "List projects in a workspace. Returns id, name, clientId, billable, color, archived, public, estimate, hourlyRate.", + "parameters": { + "type": "object", + "properties": { + "workspace_id": { "type": "string", "description": "Workspace ID." }, + "name": { "type": "string", "description": "Substring filter." }, + "clients": { "type": "string", "description": "Comma-separated client IDs to filter to." }, + "archived": { "type": "boolean", "description": "Include archived." }, + "billable": { "type": "boolean", "description": "Filter billable." }, + "page": { "type": "integer", "description": "1-based." }, + "page_size": { "type": "integer", "description": "Default 50." } + }, + "required": ["workspace_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/workspaces/{workspace_id}/projects", + "queryParams": { + "name": "$name", + "clients": "$clients", + "archived": "$archived", + "billable": "$billable", + "page": "$page", + "page-size": "$page_size" + } + } + }, + { + "name": "clockify_list_tags", + "description": "List tags in a workspace.", + "parameters": { + "type": "object", + "properties": { + "workspace_id": { "type": "string", "description": "Workspace ID." }, + "name": { "type": "string", "description": "Substring filter." }, + "archived": { "type": "boolean", "description": "Include archived." } + }, + "required": ["workspace_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/workspaces/{workspace_id}/tags", + "queryParams": { "name": "$name", "archived": "$archived" } + } + }, + { + "name": "clockify_list_time_entries", + "description": "List a user's time entries in a workspace. Filter by start/end window, project, tag, billable. Returns each entry's id, description, project, task, tags, billable, timeInterval (start/end/duration).", + "parameters": { + "type": "object", + "properties": { + "workspace_id": { "type": "string", "description": "Workspace ID." }, + "user_id": { "type": "string", "description": "User ID (use clockify_get_user for own ID)." }, + "start": { "type": "string", "description": "ISO 8601 UTC (e.g. 2026-05-20T00:00:00Z)." }, + "end": { "type": "string", "description": "ISO 8601 UTC." }, + "project": { "type": "string", "description": "Project ID filter." }, + "tags": { "type": "string", "description": "Comma-separated tag IDs." }, + "billable": { "type": "boolean", "description": "Filter billable." }, + "in_progress": { "type": "boolean", "description": "Only currently running." }, + "page": { "type": "integer", "description": "1-based." }, + "page_size": { "type": "integer", "description": "Default 50, max 5000." } + }, + "required": ["workspace_id", "user_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/workspaces/{workspace_id}/user/{user_id}/time-entries", + "queryParams": { + "start": "$start", + "end": "$end", + "project": "$project", + "tags": "$tags", + "billable": "$billable", + "in-progress": "$in_progress", + "page": "$page", + "page-size": "$page_size" + } + } + }, + { + "name": "clockify_start_time_entry", + "description": "Start a new time entry. Omit `end` to start a running timer; supply both `start` and `end` to log a completed entry. Starting a new entry stops any currently-running one for the same user.", + "parameters": { + "type": "object", + "properties": { + "workspace_id": { "type": "string", "description": "Workspace ID." }, + "start": { "type": "string", "description": "ISO 8601 UTC start time." }, + "end": { "type": "string", "description": "ISO 8601 UTC end time (omit for running)." }, + "billable": { "type": "boolean", "description": "Billable." }, + "description": { "type": "string", "description": "Free-text." }, + "project_id": { "type": "string", "description": "Project ID." }, + "task_id": { "type": "string", "description": "Task ID inside the project." }, + "tag_ids": { "type": "array", "description": "Array of tag ID strings." } + }, + "required": ["workspace_id", "start"] + }, + "endpointMapping": { + "method": "POST", + "path": "/workspaces/{workspace_id}/time-entries", + "bodyMapping": { + "start": "$start", + "end": "$end", + "billable": "$billable", + "description": "$description", + "projectId": "$project_id", + "taskId": "$task_id", + "tagIds": "$tag_ids" + } + } + }, + { + "name": "clockify_stop_current_entry", + "description": "Stop the currently running time entry for a user. Sets `end` to the supplied timestamp (default now).", + "parameters": { + "type": "object", + "properties": { + "workspace_id": { "type": "string", "description": "Workspace ID." }, + "user_id": { "type": "string", "description": "User ID." }, + "end": { "type": "string", "description": "ISO 8601 UTC end time." } + }, + "required": ["workspace_id", "user_id", "end"] + }, + "endpointMapping": { + "method": "PATCH", + "path": "/workspaces/{workspace_id}/user/{user_id}/time-entries", + "bodyMapping": { "end": "$end" } + } + }, + { + "name": "clockify_update_time_entry", + "description": "Update a time entry's description, project, task, tags, billable status, or time range.", + "parameters": { + "type": "object", + "properties": { + "workspace_id": { "type": "string", "description": "Workspace ID." }, + "time_entry_id": { "type": "string", "description": "Time entry ID." }, + "start": { "type": "string", "description": "New start (ISO 8601 UTC)." }, + "end": { "type": "string", "description": "New end (ISO 8601 UTC)." }, + "billable": { "type": "boolean", "description": "Billable." }, + "description": { "type": "string", "description": "New description." }, + "project_id": { "type": "string", "description": "Move to project." }, + "task_id": { "type": "string", "description": "Move to task." }, + "tag_ids": { "type": "array", "description": "Replace tags." } + }, + "required": ["workspace_id", "time_entry_id", "start"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/workspaces/{workspace_id}/time-entries/{time_entry_id}", + "bodyMapping": { + "start": "$start", + "end": "$end", + "billable": "$billable", + "description": "$description", + "projectId": "$project_id", + "taskId": "$task_id", + "tagIds": "$tag_ids" + } + } + }, + { + "name": "clockify_delete_time_entry", + "description": "Permanently delete a time entry.", + "parameters": { + "type": "object", + "properties": { + "workspace_id": { "type": "string", "description": "Workspace ID." }, + "time_entry_id": { "type": "string", "description": "Time entry ID." } + }, + "required": ["workspace_id", "time_entry_id"] + }, + "endpointMapping": { + "method": "DELETE", + "path": "/workspaces/{workspace_id}/time-entries/{time_entry_id}" + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/clockify.live.spec.ts b/packages/backend/src/adapters/intl/clockify.live.spec.ts new file mode 100644 index 0000000..f2982cf --- /dev/null +++ b/packages/backend/src/adapters/intl/clockify.live.spec.ts @@ -0,0 +1,12 @@ +import * as adapter from './clockify.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: { headerName: string } }; +}; +describe('clockify adapter — static spec conformance', () => { + it('api.clockify.me/api/v1 base URL', () => + expect(a.connector.baseUrl).toBe('https://api.clockify.me/api/v1')); + it('X-Api-Key auth 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/coingecko.json b/packages/backend/src/adapters/intl/coingecko.json new file mode 100644 index 0000000..c57edb0 --- /dev/null +++ b/packages/backend/src/adapters/intl/coingecko.json @@ -0,0 +1,168 @@ +{ + "slug": "coingecko", + "name": "CoinGecko", + "description": "Query CoinGecko (crypto prices, market data, historical, exchanges, NFT floor prices) from any AI agent. 8 tools, optional API-key auth.", + "instructions": "This connector wraps the CoinGecko REST API.\n\n**Setup**:\n1. The PUBLIC API (api.coingecko.com/api/v3) requires NO API key but is harshly rate-limited (~10-30 calls/min) and missing some endpoints.\n2. For higher throughput, sign up for the free DEMO plan at https://www.coingecko.com/en/developers/dashboard → create an `x-cg-demo-api-key`. This raises the limit to 30 calls/min and stays free.\n3. Paid PRO plans use a different host (pro-api.coingecko.com) and header `x-cg-pro-api-key` — switch baseUrl + auth header accordingly.\n4. Set `COINGECKO_API_KEY` if you have one; leave unset for anonymous public access.\n\n**Authentication**: optional. When `COINGECKO_API_KEY` is set, the adapter sends `x-cg-demo-api-key: ${KEY}`. When unset, no auth header is sent.\n\n**Coin IDs not symbols**: CoinGecko endpoints want the internal coin ID (`bitcoin`, `ethereum`, `solana`), not the ticker (`BTC`, `ETH`, `SOL`). Use `coingecko_search` or `coingecko_list_coins` first to look up the canonical ID. The mapping is one-way: 'BTC' → 'bitcoin'.\n\n**Pagination**: most list endpoints support `per_page` (1-250) and `page` (1-based). Default `per_page` is 100.\n\n**Currency codes**: pass `vs_currency=usd|eur|btc|eth` lowercase. The full list is at `/simple/supported_vs_currencies`.\n\n**Rate limits**: 429 responses come with a `Retry-After` header — respect it. Public tier: ~10-30/min; Demo: 30/min; Pro: from 500/min.\n\n**Out of scope here**: streaming/WebSocket price feeds, DEX-specific GraphQL endpoints (use the dedicated Onchain DEX API instead), CSV bulk export.", + "region": "intl", + "category": "data", + "icon": "coingecko", + "docsUrl": "https://docs.coingecko.com/reference/introduction", + "requiredEnvVars": [], + "connector": { + "name": "CoinGecko API v3", + "type": "REST", + "baseUrl": "https://api.coingecko.com/api/v3", + "authType": "API_KEY", + "authConfig": { + "headerName": "x-cg-demo-api-key", + "apiKey": "{{COINGECKO_API_KEY}}" + } + }, + "tools": [ + { + "name": "coingecko_ping", + "description": "Sanity check — returns `{gecko_says: '(V3) To the Moon!'}` if the API is up. Useful as a connectivity probe.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/ping" } + }, + { + "name": "coingecko_simple_price", + "description": "Cheapest way to get current price(s). Pass comma-separated coin IDs and currencies. Optionally include 24h volume, market cap, last-updated, 24h change. Use this for 'what's the price of X' — much faster than full market data.", + "parameters": { + "type": "object", + "properties": { + "ids": { "type": "string", "description": "Comma-separated CoinGecko coin IDs, e.g. 'bitcoin,ethereum,solana'." }, + "vs_currencies": { "type": "string", "description": "Comma-separated target currencies, e.g. 'usd,eur'." }, + "include_market_cap": { "type": "boolean", "description": "Include market cap." }, + "include_24hr_vol": { "type": "boolean", "description": "Include 24h volume." }, + "include_24hr_change": { "type": "boolean", "description": "Include 24h % change." }, + "include_last_updated_at": { "type": "boolean", "description": "Include last-updated UNIX timestamp." } + }, + "required": ["ids", "vs_currencies"] + }, + "endpointMapping": { + "method": "GET", + "path": "/simple/price", + "queryParams": { + "ids": "$ids", + "vs_currencies": "$vs_currencies", + "include_market_cap": "$include_market_cap", + "include_24hr_vol": "$include_24hr_vol", + "include_24hr_change": "$include_24hr_change", + "include_last_updated_at": "$include_last_updated_at" + } + } + }, + { + "name": "coingecko_search", + "description": "Search coins, categories and markets by free-text query. Returns matching CoinGecko IDs — use this when the user gives a ticker or partial name and you need the canonical ID for downstream calls.", + "parameters": { + "type": "object", + "properties": { + "query": { "type": "string", "description": "Search text, e.g. 'bitcoin' or 'BTC' or 'arbitr'." } + }, + "required": ["query"] + }, + "endpointMapping": { + "method": "GET", + "path": "/search", + "queryParams": { "query": "$query" } + } + }, + { + "name": "coingecko_list_coins", + "description": "Full list of every coin with id, symbol, name. Heavy (~13k entries) — prefer `coingecko_search` unless you really need the whole map.", + "parameters": { + "type": "object", + "properties": { + "include_platform": { "type": "boolean", "description": "Include contract address on each chain." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/coins/list", + "queryParams": { "include_platform": "$include_platform" } + } + }, + { + "name": "coingecko_coin_markets", + "description": "Market data for top coins by market cap. Returns price, market cap, volume, supply, ATH/ATL, 24h/7d/30d % change. Sorted by market cap desc by default.", + "parameters": { + "type": "object", + "properties": { + "vs_currency": { "type": "string", "description": "Target currency, e.g. 'usd'." }, + "ids": { "type": "string", "description": "Optional comma-separated coin IDs to filter to." }, + "order": { "type": "string", "description": "market_cap_desc, market_cap_asc, volume_desc, volume_asc, id_asc, id_desc." }, + "per_page": { "type": "integer", "description": "1-250. Default 100." }, + "page": { "type": "integer", "description": "1-based page index." }, + "price_change_percentage": { "type": "string", "description": "Comma-separated windows: 1h,24h,7d,14d,30d,200d,1y." } + }, + "required": ["vs_currency"] + }, + "endpointMapping": { + "method": "GET", + "path": "/coins/markets", + "queryParams": { + "vs_currency": "$vs_currency", + "ids": "$ids", + "order": "$order", + "per_page": "$per_page", + "page": "$page", + "price_change_percentage": "$price_change_percentage" + } + } + }, + { + "name": "coingecko_coin_detail", + "description": "Full profile of one coin: description in many languages, links, image URLs, market data, community + developer stats, public interest score. Heavy payload — prefer simple_price for snippets.", + "parameters": { + "type": "object", + "properties": { + "id": { "type": "string", "description": "CoinGecko coin ID (e.g. 'bitcoin')." }, + "localization": { "type": "boolean", "description": "Include localized descriptions (large response)." }, + "tickers": { "type": "boolean", "description": "Include per-exchange tickers." }, + "market_data": { "type": "boolean", "description": "Include market data block." }, + "community_data": { "type": "boolean", "description": "Include Reddit / Twitter follower counts." }, + "developer_data": { "type": "boolean", "description": "Include GitHub stars/forks/PRs." } + }, + "required": ["id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/coins/{id}", + "queryParams": { + "localization": "$localization", + "tickers": "$tickers", + "market_data": "$market_data", + "community_data": "$community_data", + "developer_data": "$developer_data" + } + } + }, + { + "name": "coingecko_coin_market_chart", + "description": "Historical price/marketcap/volume series for a coin over a window. Granularity auto-selected by CoinGecko based on `days`: minutely for 1, hourly for 2-90, daily for >90.", + "parameters": { + "type": "object", + "properties": { + "id": { "type": "string", "description": "CoinGecko coin ID." }, + "vs_currency": { "type": "string", "description": "Target currency." }, + "days": { "type": "string", "description": "Window: '1', '7', '30', '90', '365', 'max'." }, + "interval": { "type": "string", "description": "Override granularity. Most users leave this empty." } + }, + "required": ["id", "vs_currency", "days"] + }, + "endpointMapping": { + "method": "GET", + "path": "/coins/{id}/market_chart", + "queryParams": { "vs_currency": "$vs_currency", "days": "$days", "interval": "$interval" } + } + }, + { + "name": "coingecko_trending", + "description": "Top-7 trending coins on CoinGecko in the last 24h (by search volume). Bonus payload: top NFT collections + categories trending.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/search/trending" } + } + ] +} diff --git a/packages/backend/src/adapters/intl/coingecko.live.spec.ts b/packages/backend/src/adapters/intl/coingecko.live.spec.ts new file mode 100644 index 0000000..699be3a --- /dev/null +++ b/packages/backend/src/adapters/intl/coingecko.live.spec.ts @@ -0,0 +1,12 @@ +import * as adapter from './coingecko.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: { headerName: string } }; +}; +describe('coingecko adapter — static spec conformance', () => { + it('api.coingecko.com/api/v3 base URL', () => + expect(a.connector.baseUrl).toBe('https://api.coingecko.com/api/v3')); + it('demo API key sent as x-cg-demo-api-key header', () => { + expect(a.connector.authType).toBe('API_KEY'); + expect(a.connector.authConfig.headerName).toBe('x-cg-demo-api-key'); + }); +}); diff --git a/packages/backend/src/adapters/intl/datadog.json b/packages/backend/src/adapters/intl/datadog.json new file mode 100644 index 0000000..8695100 --- /dev/null +++ b/packages/backend/src/adapters/intl/datadog.json @@ -0,0 +1,192 @@ +{ + "slug": "datadog", + "name": "Datadog", + "description": "Query Datadog (metrics, logs, monitors, events, incidents, dashboards) from any AI agent. 8 tools, dual-key auth.", + "instructions": "This connector wraps the Datadog REST API v1/v2 (api.datadoghq.com).\n\n**Setup**:\n1. In your Datadog org → **Personal Settings → Organization Settings → API Keys** → create or copy an API key. Set `DATADOG_API_KEY`.\n2. **Organization Settings → Application Keys** → create an application key with at least `monitors_read`, `logs_read_data`, `dashboards_read`, `events_read` scopes. Set `DATADOG_APP_KEY`.\n3. **Site matters**: Datadog has multiple regions: US1 (datadoghq.com — default), US3 (us3.datadoghq.com), US5 (us5.datadoghq.com), EU1 (datadoghq.eu), AP1 (ap1.datadoghq.com), US1-FED (ddog-gov.com). If your org is on a non-US1 site, change `baseUrl` accordingly. Wrong site = 403 with `Forbidden` and no useful body.\n\n**Authentication**: Datadog requires BOTH headers on every request:\n- `DD-API-KEY: ${DATADOG_API_KEY}` — identifies the org\n- `DD-APPLICATION-KEY: ${DATADOG_APP_KEY}` — identifies the user/permissions\n\n**Metrics query language**: `q` uses the Datadog metric query syntax — `avg:system.cpu.user{*}` or `sum:trace.web.request.hits{env:prod} by {service}`. See https://docs.datadoghq.com/dashboards/functions/.\n\n**Time windows**: `from` / `to` are UNIX epoch seconds (NOT milliseconds, NOT ISO strings). Default windows: last 1h for metrics, last 15min for logs.\n\n**Logs search**: uses the same query syntax as the Logs Explorer UI — `service:api status:error @http.status_code:>=500`. Returns `data[]` with `attributes.message`, `attributes.tags`, etc.\n\n**Rate limits**: most read endpoints are 300 req/h per org. Monitors API allows 100/min. 429 responses include `X-RateLimit-Reset` (epoch seconds).\n\n**Out of scope here**: agent install / install-script generation, SLO CRUD beyond list, RUM/synthetics CRUD.", + "region": "intl", + "category": "monitoring", + "icon": "datadog", + "docsUrl": "https://docs.datadoghq.com/api/latest/", + "requiredEnvVars": ["DATADOG_API_KEY", "DATADOG_APP_KEY"], + "connector": { + "name": "Datadog API", + "type": "REST", + "baseUrl": "https://api.datadoghq.com", + "authType": "API_KEY", + "authConfig": { + "headerName": "DD-API-KEY", + "apiKey": "{{DATADOG_API_KEY}}", + "extraHeaders": { + "DD-APPLICATION-KEY": "{{DATADOG_APP_KEY}}" + } + } + }, + "tools": [ + { + "name": "datadog_query_metrics", + "description": "Run a metrics query over a time window. `q` uses the Datadog metric query language: e.g. `avg:system.cpu.user{*}` or `sum:requests{service:api,env:prod} by {host}`. Returns a series array with per-point [timestamp, value] pairs.", + "parameters": { + "type": "object", + "properties": { + "from": { "type": "integer", "description": "Start UNIX epoch seconds." }, + "to": { "type": "integer", "description": "End UNIX epoch seconds." }, + "query": { "type": "string", "description": "Datadog metric query expression." } + }, + "required": ["from", "to", "query"] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v1/query", + "queryParams": { "from": "$from", "to": "$to", "query": "$query" } + } + }, + { + "name": "datadog_search_logs", + "description": "Search logs using the Logs Search syntax. Use this for 'show me errors in the last hour' or 'find logs containing X in service Y'.", + "parameters": { + "type": "object", + "properties": { + "query": { "type": "string", "description": "Logs query string, e.g. 'service:api status:error'." }, + "from": { "type": "string", "description": "Start time: ISO 8601 OR relative like 'now-15m'." }, + "to": { "type": "string", "description": "End time: ISO 8601 OR 'now'." }, + "limit": { "type": "integer", "description": "Max events (1-1000). Default 10." }, + "sort": { "type": "string", "description": "'timestamp' (asc) or '-timestamp' (desc)." } + }, + "required": ["query"] + }, + "endpointMapping": { + "method": "POST", + "path": "/api/v2/logs/events/search", + "bodyMapping": { + "filter": { "query": "$query", "from": "$from", "to": "$to" }, + "page": { "limit": "$limit" }, + "sort": "$sort" + } + } + }, + { + "name": "datadog_list_monitors", + "description": "List monitors. Filter by tags, name match, or monitor type. Returns each monitor's id, name, query, type, message, state.", + "parameters": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Filter by name substring." }, + "tags": { "type": "string", "description": "Comma-separated tag list (AND)." }, + "monitor_tags": { "type": "string", "description": "Filter by monitor-level tags." }, + "page": { "type": "integer", "description": "0-based page index." }, + "page_size": { "type": "integer", "description": "Per page (max 100)." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v1/monitor", + "queryParams": { + "name": "$name", + "tags": "$tags", + "monitor_tags": "$monitor_tags", + "page": "$page", + "page_size": "$page_size" + } + } + }, + { + "name": "datadog_get_monitor", + "description": "Get a single monitor by ID with full definition (query, thresholds, notifications, state).", + "parameters": { + "type": "object", + "properties": { + "monitor_id": { "type": "integer", "description": "Monitor numeric ID." } + }, + "required": ["monitor_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v1/monitor/{monitor_id}" + } + }, + { + "name": "datadog_list_dashboards", + "description": "List all dashboards in the org. Returns each dashboard's id, title, url, author, modified_at. Use this to find a dashboard ID before fetching its full layout.", + "parameters": { + "type": "object", + "properties": { + "filter_shared": { "type": "boolean", "description": "If true, only shared dashboards." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v1/dashboard", + "queryParams": { "filter[shared]": "$filter_shared" } + } + }, + { + "name": "datadog_get_dashboard", + "description": "Fetch a dashboard's full layout: widgets, queries, template variables.", + "parameters": { + "type": "object", + "properties": { + "dashboard_id": { "type": "string", "description": "Dashboard ID." } + }, + "required": ["dashboard_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v1/dashboard/{dashboard_id}" + } + }, + { + "name": "datadog_list_events", + "description": "List events in a time range. Use for 'what deploys/alerts/changes happened today'. Filter by tags, priority, sources, or text query.", + "parameters": { + "type": "object", + "properties": { + "start": { "type": "integer", "description": "Start UNIX epoch seconds." }, + "end": { "type": "integer", "description": "End UNIX epoch seconds." }, + "priority": { "type": "string", "description": "'normal' or 'low'." }, + "sources": { "type": "string", "description": "Comma-separated sources, e.g. 'github,jira'." }, + "tags": { "type": "string", "description": "Comma-separated tags." }, + "unaggregated": { "type": "boolean", "description": "If true, return all events (not deduped/aggregated)." } + }, + "required": ["start", "end"] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v1/events", + "queryParams": { + "start": "$start", + "end": "$end", + "priority": "$priority", + "sources": "$sources", + "tags": "$tags", + "unaggregated": "$unaggregated" + } + } + }, + { + "name": "datadog_post_event", + "description": "Post an event to the Datadog event stream (useful for marking deploys, releases, manual incidents). Returns the created event's id + url.", + "parameters": { + "type": "object", + "properties": { + "title": { "type": "string", "description": "Event title." }, + "text": { "type": "string", "description": "Event body (supports @-mentions and Markdown)." }, + "tags": { "type": "array", "description": "Tag strings like 'env:prod' or 'service:api'." }, + "alert_type": { "type": "string", "description": "'info', 'warning', 'error', 'success', 'user_update', 'recommendation', 'snapshot'." }, + "priority": { "type": "string", "description": "'normal' or 'low'." } + }, + "required": ["title", "text"] + }, + "endpointMapping": { + "method": "POST", + "path": "/api/v1/events", + "bodyMapping": { + "title": "$title", + "text": "$text", + "tags": "$tags", + "alert_type": "$alert_type", + "priority": "$priority" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/datadog.live.spec.ts b/packages/backend/src/adapters/intl/datadog.live.spec.ts new file mode 100644 index 0000000..87a6160 --- /dev/null +++ b/packages/backend/src/adapters/intl/datadog.live.spec.ts @@ -0,0 +1,17 @@ +import * as adapter from './datadog.json'; +const a = adapter as unknown as { + connector: { + baseUrl: string; + authType: string; + authConfig: { headerName: string; extraHeaders: Record }; + }; +}; +describe('datadog adapter — static spec conformance', () => { + it('api.datadoghq.com base URL (US1 default)', () => + expect(a.connector.baseUrl).toBe('https://api.datadoghq.com')); + it('dual-header auth: DD-API-KEY + DD-APPLICATION-KEY', () => { + expect(a.connector.authType).toBe('API_KEY'); + expect(a.connector.authConfig.headerName).toBe('DD-API-KEY'); + expect(Object.keys(a.connector.authConfig.extraHeaders)).toContain('DD-APPLICATION-KEY'); + }); +}); diff --git a/packages/backend/src/adapters/intl/freshservice.json b/packages/backend/src/adapters/intl/freshservice.json new file mode 100644 index 0000000..2df84ac --- /dev/null +++ b/packages/backend/src/adapters/intl/freshservice.json @@ -0,0 +1,262 @@ +{ + "slug": "freshservice", + "name": "Freshservice", + "description": "Manage Freshservice (ITSM: tickets, problems, changes, assets, agents, requesters, departments) from any AI agent. 10 tools, basic-auth with API key.", + "instructions": "This connector wraps the Freshservice API v2 (per-tenant — yourdomain.freshservice.com/api/v2).\n\n**Setup**:\n1. Log into your Freshservice instance → top-right avatar → **Profile Settings → View API Key** (you may need to log in again to reveal).\n2. Note your tenant subdomain (the part before `.freshservice.com`).\n3. Set `FRESHSERVICE_SUBDOMAIN` (just the subdomain) and `FRESHSERVICE_API_KEY`.\n\n**Authentication**: HTTP Basic, with the API key as username and the literal string `X` as the password:\n `Authorization: Basic base64(API_KEY:X)`\nThe adapter sets this via BASIC_AUTH (username = api key, password = 'X'). Using your account password instead of the API key is allowed but discouraged.\n\n**Per-tenant baseUrl**: `https://${FRESHSERVICE_SUBDOMAIN}.freshservice.com/api/v2`. Substituted at import time.\n\n**Ticket statuses** (integer codes):\n- 2: Open\n- 3: Pending\n- 4: Resolved\n- 5: Closed\nPriorities: 1=Low, 2=Medium, 3=High, 4=Urgent. Sources: 1=Email, 2=Portal, 3=Phone, 4=Chat, 5=Feedback widget, 6=Yammer, 7=AWS Cloudwatch, 8=Pagerduty, 9=Walkup, 10=Slack.\n\n**ITIL records**: Freshservice exposes Tickets, Problems, Changes, Releases. They share much of the same shape but live at different endpoints.\n\n**Pagination**: `page` (1-based) + `per_page` (max 100). Header `Link` carries the next-page URL. Many list endpoints cap at 9 pages total — for full export use the dedicated archive APIs.\n\n**Rate limits**: 100 req/min per API key on Starter, up to 1k/min on Enterprise. 429 returns `Retry-After` in seconds.\n\n**Out of scope here**: workflow automation, project portfolio management, contract CRUD beyond list, asset relationships graph.", + "region": "intl", + "category": "support", + "icon": "freshservice", + "docsUrl": "https://api.freshservice.com/v2/", + "requiredEnvVars": ["FRESHSERVICE_SUBDOMAIN", "FRESHSERVICE_API_KEY"], + "connector": { + "name": "Freshservice API v2", + "type": "REST", + "baseUrl": "https://{{FRESHSERVICE_SUBDOMAIN}}.freshservice.com/api/v2", + "authType": "BASIC_AUTH", + "authConfig": { + "username": "{{FRESHSERVICE_API_KEY}}", + "password": "X" + } + }, + "tools": [ + { + "name": "freshservice_list_tickets", + "description": "List tickets. Filter by predefined views (`new_and_my_open`, `watching`, `spam`, `deleted`, `unresolved`, ...) or by requester_id/email. Returns each ticket's id, subject, status, priority, source, group_id, responder_id, requester_id, created_at.", + "parameters": { + "type": "object", + "properties": { + "filter": { "type": "string", "description": "Predefined view name." }, + "requester_id": { "type": "integer", "description": "Filter to one requester." }, + "email": { "type": "string", "description": "Filter to one requester email." }, + "updated_since": { "type": "string", "description": "ISO 8601 — only tickets updated after this." }, + "include": { "type": "string", "description": "Comma-separated: 'stats', 'requester', 'requested_for', 'department'." }, + "page": { "type": "integer", "description": "1-based." }, + "per_page": { "type": "integer", "description": "Max 100." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/tickets", + "queryParams": { + "filter": "$filter", + "requester_id": "$requester_id", + "email": "$email", + "updated_since": "$updated_since", + "include": "$include", + "page": "$page", + "per_page": "$per_page" + } + } + }, + { + "name": "freshservice_get_ticket", + "description": "Get one ticket by ID with full description + custom fields. Pass `include` to expand stats/requester/conversations.", + "parameters": { + "type": "object", + "properties": { + "ticket_id": { "type": "integer", "description": "Ticket ID." }, + "include": { "type": "string", "description": "Comma-separated: 'conversations', 'requester', 'requested_for', 'stats', 'problem', 'assets', 'change', 'related_tickets'." } + }, + "required": ["ticket_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/tickets/{ticket_id}", + "queryParams": { "include": "$include" } + } + }, + { + "name": "freshservice_create_ticket", + "description": "Create a new ticket. Required: `subject` + `description` + `email` (requester) + `priority` + `status` + `source`.", + "parameters": { + "type": "object", + "properties": { + "subject": { "type": "string", "description": "Ticket subject." }, + "description": { "type": "string", "description": "HTML description." }, + "email": { "type": "string", "description": "Requester email." }, + "priority": { "type": "integer", "description": "1=Low, 2=Medium, 3=High, 4=Urgent." }, + "status": { "type": "integer", "description": "2=Open, 3=Pending, 4=Resolved, 5=Closed." }, + "source": { "type": "integer", "description": "1=Email, 2=Portal, 3=Phone, 4=Chat, ..." }, + "category": { "type": "string", "description": "Top-level category name." }, + "sub_category": { "type": "string", "description": "Second-level category." }, + "item_category": { "type": "string", "description": "Third-level category." }, + "group_id": { "type": "integer", "description": "Assign to a group." }, + "responder_id": { "type": "integer", "description": "Assign to an agent." }, + "department_id": { "type": "integer", "description": "Department." }, + "tags": { "type": "array", "description": "Array of tag strings." }, + "custom_fields": { "type": "object", "description": "Custom field map (key = field name)." } + }, + "required": ["subject", "description", "email", "priority", "status", "source"] + }, + "endpointMapping": { + "method": "POST", + "path": "/tickets", + "bodyMapping": { + "subject": "$subject", + "description": "$description", + "email": "$email", + "priority": "$priority", + "status": "$status", + "source": "$source", + "category": "$category", + "sub_category": "$sub_category", + "item_category": "$item_category", + "group_id": "$group_id", + "responder_id": "$responder_id", + "department_id": "$department_id", + "tags": "$tags", + "custom_fields": "$custom_fields" + } + } + }, + { + "name": "freshservice_update_ticket", + "description": "Update a ticket's status, priority, assignee, tags, custom fields, subject, or description.", + "parameters": { + "type": "object", + "properties": { + "ticket_id": { "type": "integer", "description": "Ticket ID." }, + "subject": { "type": "string", "description": "New subject." }, + "description": { "type": "string", "description": "New HTML description." }, + "priority": { "type": "integer", "description": "1-4." }, + "status": { "type": "integer", "description": "2-5." }, + "group_id": { "type": "integer", "description": "Move to group." }, + "responder_id": { "type": "integer", "description": "Move to agent." }, + "tags": { "type": "array", "description": "Replace tags." }, + "custom_fields": { "type": "object", "description": "Merge custom fields." } + }, + "required": ["ticket_id"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/tickets/{ticket_id}", + "bodyMapping": { + "subject": "$subject", + "description": "$description", + "priority": "$priority", + "status": "$status", + "group_id": "$group_id", + "responder_id": "$responder_id", + "tags": "$tags", + "custom_fields": "$custom_fields" + } + } + }, + { + "name": "freshservice_reply_to_ticket", + "description": "Add a public reply to a ticket — visible to the requester.", + "parameters": { + "type": "object", + "properties": { + "ticket_id": { "type": "integer", "description": "Ticket ID." }, + "body": { "type": "string", "description": "HTML body of the reply." }, + "from_email": { "type": "string", "description": "Override sending email." }, + "user_id": { "type": "integer", "description": "Agent user_id." } + }, + "required": ["ticket_id", "body"] + }, + "endpointMapping": { + "method": "POST", + "path": "/tickets/{ticket_id}/reply", + "bodyMapping": { "body": "$body", "from_email": "$from_email", "user_id": "$user_id" } + } + }, + { + "name": "freshservice_add_note", + "description": "Add an internal (private) note to a ticket — NOT visible to the requester. Pass `private=true` (default) for internal, `false` for public.", + "parameters": { + "type": "object", + "properties": { + "ticket_id": { "type": "integer", "description": "Ticket ID." }, + "body": { "type": "string", "description": "HTML body." }, + "private": { "type": "boolean", "description": "Default true." }, + "user_id": { "type": "integer", "description": "Author agent user_id." } + }, + "required": ["ticket_id", "body"] + }, + "endpointMapping": { + "method": "POST", + "path": "/tickets/{ticket_id}/notes", + "bodyMapping": { "body": "$body", "private": "$private", "user_id": "$user_id" } + } + }, + { + "name": "freshservice_list_problems", + "description": "List problem records.", + "parameters": { + "type": "object", + "properties": { + "page": { "type": "integer", "description": "1-based." }, + "per_page": { "type": "integer", "description": "Max 100." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/problems", + "queryParams": { "page": "$page", "per_page": "$per_page" } + } + }, + { + "name": "freshservice_list_changes", + "description": "List change requests.", + "parameters": { + "type": "object", + "properties": { + "page": { "type": "integer", "description": "1-based." }, + "per_page": { "type": "integer", "description": "Max 100." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/changes", + "queryParams": { "page": "$page", "per_page": "$per_page" } + } + }, + { + "name": "freshservice_list_assets", + "description": "List CI assets in the Freshservice CMDB. Filter by include or asset_type_id.", + "parameters": { + "type": "object", + "properties": { + "include": { "type": "string", "description": "'type_fields' to include CI-type custom fields." }, + "filter": { "type": "string", "description": "Query like \"asset_type_id:7 AND impact:'high'\"." }, + "page": { "type": "integer", "description": "1-based." }, + "per_page": { "type": "integer", "description": "Max 100." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/assets", + "queryParams": { + "include": "$include", + "filter": "$filter", + "page": "$page", + "per_page": "$per_page" + } + } + }, + { + "name": "freshservice_list_agents", + "description": "List agents (your IT team members).", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "Filter by email." }, + "state": { "type": "string", "description": "'fulltime' or 'occasional'." }, + "page": { "type": "integer", "description": "1-based." }, + "per_page": { "type": "integer", "description": "Max 100." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/agents", + "queryParams": { + "email": "$email", + "state": "$state", + "page": "$page", + "per_page": "$per_page" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/freshservice.live.spec.ts b/packages/backend/src/adapters/intl/freshservice.live.spec.ts new file mode 100644 index 0000000..6753155 --- /dev/null +++ b/packages/backend/src/adapters/intl/freshservice.live.spec.ts @@ -0,0 +1,12 @@ +import * as adapter from './freshservice.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: { password: string } }; +}; +describe('freshservice adapter — static spec conformance', () => { + it('per-tenant baseUrl with subdomain placeholder', () => + expect(a.connector.baseUrl).toBe('https://{{FRESHSERVICE_SUBDOMAIN}}.freshservice.com/api/v2')); + it('Basic auth with literal password "X"', () => { + expect(a.connector.authType).toBe('BASIC_AUTH'); + expect(a.connector.authConfig.password).toBe('X'); + }); +}); diff --git a/packages/backend/src/adapters/intl/gorgias.json b/packages/backend/src/adapters/intl/gorgias.json new file mode 100644 index 0000000..34daa23 --- /dev/null +++ b/packages/backend/src/adapters/intl/gorgias.json @@ -0,0 +1,253 @@ +{ + "slug": "gorgias", + "name": "Gorgias", + "description": "Manage Gorgias (e-commerce helpdesk) tickets, messages, customers, integrations from any AI agent. 10 tools, basic-auth.", + "instructions": "This connector wraps the Gorgias REST API (per-subdomain).\n\n**Setup**:\n1. Log into your Gorgias dashboard at https://YOUR_SUBDOMAIN.gorgias.com → **Settings → REST API**.\n2. Click **Add new API Key**. The page shows your **email** (the account email) and a freshly-generated **API key**.\n3. Set `GORGIAS_SUBDOMAIN` (just the subdomain part, e.g. `mystore`), `GORGIAS_EMAIL`, `GORGIAS_API_KEY`.\n\n**Authentication**: HTTP Basic with `username = ${GORGIAS_EMAIL}` and `password = ${GORGIAS_API_KEY}`. The adapter wires this via BASIC_AUTH.\n\n**Per-tenant baseUrl**: Gorgias is hosted per-subdomain — `https://${GORGIAS_SUBDOMAIN}.gorgias.com/api`. baseUrl uses {{}} substitution at import time.\n\n**Ticket model**: `Ticket → Messages[]`. A ticket is a conversation thread with a customer; each message is one inbound/outbound email/chat/SMS. To reply, POST a new message to the ticket; to close, PUT the ticket with `status: 'closed'`.\n\n**Channels**: Gorgias unifies email, chat, SMS, Instagram DM, Facebook DM, Shopify, voice. `via` field on each message identifies the channel.\n\n**Pagination**: cursor-based. List endpoints return `meta.next_cursor` and `meta.previous_cursor`. Use `cursor` query param for the next page; `limit` controls page size (max 100).\n\n**Search**: the `tickets` endpoint accepts `view_id` to filter by saved view, and various direct filters. For more complex queries use the dedicated `/views/{id}/items` endpoint.\n\n**Rate limits**: 40 req/s per account; 2 concurrent connections. 429 returns `Retry-After` in seconds.\n\n**Out of scope here**: macro CRUD beyond list, rule management, satisfaction surveys, voice call orchestration.", + "region": "intl", + "category": "support", + "icon": "gorgias", + "docsUrl": "https://developers.gorgias.com/reference/introduction", + "requiredEnvVars": ["GORGIAS_SUBDOMAIN", "GORGIAS_EMAIL", "GORGIAS_API_KEY"], + "connector": { + "name": "Gorgias REST API", + "type": "REST", + "baseUrl": "https://{{GORGIAS_SUBDOMAIN}}.gorgias.com/api", + "authType": "BASIC_AUTH", + "authConfig": { + "username": "{{GORGIAS_EMAIL}}", + "password": "{{GORGIAS_API_KEY}}" + } + }, + "tools": [ + { + "name": "gorgias_list_tickets", + "description": "List tickets. Filter by status, channel, assigned user, customer, view. Returns each ticket's id, subject, status, channel, customer, assignee_user, last_message_datetime, created/updated timestamps.", + "parameters": { + "type": "object", + "properties": { + "view_id": { "type": "integer", "description": "Filter to a saved view's tickets." }, + "customer_id": { "type": "integer", "description": "Filter to one customer." }, + "assignee_user_id": { "type": "integer", "description": "Filter to one assigned user." }, + "status": { "type": "string", "description": "open, closed, resolved." }, + "channel": { "type": "string", "description": "email, chat, sms, etc." }, + "cursor": { "type": "string", "description": "Pagination cursor." }, + "limit": { "type": "integer", "description": "Per page (max 100)." }, + "order_by": { "type": "string", "description": "created_datetime, updated_datetime, last_message_datetime — append :asc or :desc." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/tickets", + "queryParams": { + "view_id": "$view_id", + "customer_id": "$customer_id", + "assignee_user_id": "$assignee_user_id", + "status": "$status", + "channel": "$channel", + "cursor": "$cursor", + "limit": "$limit", + "order_by": "$order_by" + } + } + }, + { + "name": "gorgias_get_ticket", + "description": "Get a single ticket by ID with its meta (does NOT include messages — use gorgias_list_ticket_messages).", + "parameters": { + "type": "object", + "properties": { + "ticket_id": { "type": "integer", "description": "Ticket ID." } + }, + "required": ["ticket_id"] + }, + "endpointMapping": { "method": "GET", "path": "/tickets/{ticket_id}" } + }, + { + "name": "gorgias_list_ticket_messages", + "description": "List messages on a ticket (the conversation thread). Each message has body_text, body_html, sender, receiver, via (channel), source.", + "parameters": { + "type": "object", + "properties": { + "ticket_id": { "type": "integer", "description": "Ticket ID." }, + "cursor": { "type": "string", "description": "Pagination cursor." }, + "limit": { "type": "integer", "description": "Per page (max 100)." } + }, + "required": ["ticket_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/tickets/{ticket_id}/messages", + "queryParams": { "cursor": "$cursor", "limit": "$limit" } + } + }, + { + "name": "gorgias_create_ticket", + "description": "Create a new ticket. Must include at least one message — Gorgias rejects empty tickets. Common pattern: pass `customer.email` + `messages: [{...}]`.", + "parameters": { + "type": "object", + "properties": { + "subject": { "type": "string", "description": "Ticket subject." }, + "status": { "type": "string", "description": "open, closed, resolved." }, + "channel": { "type": "string", "description": "email, chat, sms, api." }, + "customer": { "type": "object", "description": "Customer object {email, firstname, lastname} OR {id}." }, + "messages": { "type": "array", "description": "Array of message objects with body_text + sender + receiver + channel + via." }, + "tags": { "type": "array", "description": "Array of tag objects {name}." } + }, + "required": ["channel", "customer", "messages"] + }, + "endpointMapping": { + "method": "POST", + "path": "/tickets", + "bodyMapping": { + "subject": "$subject", + "status": "$status", + "channel": "$channel", + "customer": "$customer", + "messages": "$messages", + "tags": "$tags" + } + } + }, + { + "name": "gorgias_reply_to_ticket", + "description": "Add a reply message to an existing ticket. Pass `body_text` + `sender` (your user) + `receiver` (the customer) + `channel`. Use `via: 'help-center'` to send as outbound.", + "parameters": { + "type": "object", + "properties": { + "ticket_id": { "type": "integer", "description": "Ticket ID." }, + "body_text": { "type": "string", "description": "Plain-text body." }, + "body_html": { "type": "string", "description": "HTML body (preferred for rich content)." }, + "sender": { "type": "object", "description": "{id} of the Gorgias user sending." }, + "receiver": { "type": "object", "description": "{id} of the customer." }, + "channel": { "type": "string", "description": "email, chat, sms." }, + "from_agent": { "type": "boolean", "description": "true for outbound." }, + "via": { "type": "string", "description": "Source: 'help-center', 'api', etc." } + }, + "required": ["ticket_id", "body_text", "sender", "channel"] + }, + "endpointMapping": { + "method": "POST", + "path": "/tickets/{ticket_id}/messages", + "bodyMapping": { + "body_text": "$body_text", + "body_html": "$body_html", + "sender": "$sender", + "receiver": "$receiver", + "channel": "$channel", + "from_agent": "$from_agent", + "via": "$via" + } + } + }, + { + "name": "gorgias_update_ticket", + "description": "Update a ticket's status, assignee, tags, custom fields.", + "parameters": { + "type": "object", + "properties": { + "ticket_id": { "type": "integer", "description": "Ticket ID." }, + "status": { "type": "string", "description": "open, closed, resolved." }, + "assignee_user": { "type": "object", "description": "{id} of the Gorgias user." }, + "tags": { "type": "array", "description": "Array of {name} objects." }, + "priority": { "type": "string", "description": "low, normal, high, urgent." }, + "subject": { "type": "string", "description": "New subject." } + }, + "required": ["ticket_id"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/tickets/{ticket_id}", + "bodyMapping": { + "status": "$status", + "assignee_user": "$assignee_user", + "tags": "$tags", + "priority": "$priority", + "subject": "$subject" + } + } + }, + { + "name": "gorgias_list_customers", + "description": "List customers. Filter by email, external_id. Returns id, email, firstname, lastname, channels (linked accounts).", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "Exact match on email." }, + "external_id": { "type": "string", "description": "Exact match on external (Shopify etc.) ID." }, + "cursor": { "type": "string", "description": "Pagination cursor." }, + "limit": { "type": "integer", "description": "Per page (max 100)." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/customers", + "queryParams": { + "email": "$email", + "external_id": "$external_id", + "cursor": "$cursor", + "limit": "$limit" + } + } + }, + { + "name": "gorgias_get_customer", + "description": "Get a single customer by ID with full profile and integration data (e.g. Shopify orders count).", + "parameters": { + "type": "object", + "properties": { + "customer_id": { "type": "integer", "description": "Customer ID." } + }, + "required": ["customer_id"] + }, + "endpointMapping": { "method": "GET", "path": "/customers/{customer_id}" } + }, + { + "name": "gorgias_create_customer", + "description": "Create a customer record. Pass at least email + firstname + lastname.", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "Email." }, + "firstname": { "type": "string", "description": "First name." }, + "lastname": { "type": "string", "description": "Last name." }, + "external_id": { "type": "string", "description": "Optional external system ID (Shopify customer ID etc.)." }, + "language": { "type": "string", "description": "ISO 639-1 language code." }, + "timezone": { "type": "string", "description": "TZ database name." }, + "channels": { "type": "array", "description": "Additional channel objects: [{type:'email', address:'...'},{type:'phone', address:'+1...'}]." }, + "meta": { "type": "object", "description": "Free-form custom metadata object." } + }, + "required": ["email"] + }, + "endpointMapping": { + "method": "POST", + "path": "/customers", + "bodyMapping": { + "email": "$email", + "firstname": "$firstname", + "lastname": "$lastname", + "external_id": "$external_id", + "language": "$language", + "timezone": "$timezone", + "channels": "$channels", + "meta": "$meta" + } + } + }, + { + "name": "gorgias_list_users", + "description": "List agents in your Gorgias workspace (the people on your team — sender IDs for reply messages).", + "parameters": { + "type": "object", + "properties": { + "cursor": { "type": "string", "description": "Pagination cursor." }, + "limit": { "type": "integer", "description": "Per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/users", + "queryParams": { "cursor": "$cursor", "limit": "$limit" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/gorgias.live.spec.ts b/packages/backend/src/adapters/intl/gorgias.live.spec.ts new file mode 100644 index 0000000..8bbf9ce --- /dev/null +++ b/packages/backend/src/adapters/intl/gorgias.live.spec.ts @@ -0,0 +1,8 @@ +import * as adapter from './gorgias.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string } }; +describe('gorgias adapter — static spec conformance', () => { + it('per-subdomain baseUrl with placeholder', () => + expect(a.connector.baseUrl).toBe('https://{{GORGIAS_SUBDOMAIN}}.gorgias.com/api')); + it('Basic auth (email + API key)', () => + expect(a.connector.authType).toBe('BASIC_AUTH')); +}); diff --git a/packages/backend/src/adapters/intl/hackernews.json b/packages/backend/src/adapters/intl/hackernews.json new file mode 100644 index 0000000..3cc7443 --- /dev/null +++ b/packages/backend/src/adapters/intl/hackernews.json @@ -0,0 +1,80 @@ +{ + "slug": "hackernews", + "name": "Hacker News", + "description": "Read Hacker News (stories, comments, users, top/new/best/ask/show/job lists) from any AI agent via the official Firebase API. 8 tools, no auth.", + "instructions": "This connector wraps the official Hacker News API hosted on Firebase (hacker-news.firebaseio.com).\n\n**Setup**: nothing. The API is public, read-only, and unauthenticated.\n\n**No write access**: the official API is READ-ONLY. There is no way to post stories, comments, or vote via this connector — those require a session cookie obtained by submitting an HTML login form to news.ycombinator.com, which is out of scope for an MCP adapter.\n\n**Two-step pattern**: list endpoints return arrays of integer IDs only (e.g. top 500 story IDs). You must then fetch each ID via `hackernews_get_item` to get the actual title/url/text/score. Plan accordingly — fetching 500 top stories means 501 requests.\n\n**Item types**: `story`, `comment`, `job`, `poll`, `pollopt`. Each has different fields — comments have `parent` + `text`, stories have `title` + `url` (or `text` for Ask HN), jobs have `title` + `url` + `text`.\n\n**Comment trees**: comments are a tree via `kids` (array of IDs) on each item. Walk recursively, watch for `deleted: true` or `dead: true` entries.\n\n**Rate limits**: no published limit, but Firebase will throttle aggressive clients. Stay under ~30 req/s and you'll be fine.\n\n**Cache friendly**: every item is content-addressable by ID and immutable once posted (votes update but story body doesn't). Long TTLs work well.\n\n**Out of scope here**: HN Algolia full-text search (different API at hn.algolia.com — could be a separate adapter), write/vote, email subscription.", + "region": "intl", + "category": "data", + "icon": "hackernews", + "docsUrl": "https://github.com/HackerNews/API", + "requiredEnvVars": [], + "connector": { + "name": "Hacker News Firebase API", + "type": "REST", + "baseUrl": "https://hacker-news.firebaseio.com/v0", + "authType": "NONE", + "authConfig": {} + }, + "tools": [ + { + "name": "hackernews_top_stories", + "description": "Returns an array of up to 500 item IDs for the current front-page (top) stories. Use hackernews_get_item to resolve each ID into the actual story.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/topstories.json" } + }, + { + "name": "hackernews_new_stories", + "description": "Returns an array of up to 500 newest story IDs.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/newstories.json" } + }, + { + "name": "hackernews_best_stories", + "description": "Returns an array of up to 500 'best' story IDs (HN's algorithmic top).", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/beststories.json" } + }, + { + "name": "hackernews_ask_stories", + "description": "Returns up to 200 Ask HN story IDs.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/askstories.json" } + }, + { + "name": "hackernews_show_stories", + "description": "Returns up to 200 Show HN story IDs.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/showstories.json" } + }, + { + "name": "hackernews_job_stories", + "description": "Returns up to 200 'Who is hiring / job' story IDs.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/jobstories.json" } + }, + { + "name": "hackernews_get_item", + "description": "Fetch a single item (story, comment, job, poll, pollopt) by ID. Returns the full payload including `type`, `by`, `time`, `title`, `url`, `text`, `score`, `kids` (child comment IDs), `parent`.", + "parameters": { + "type": "object", + "properties": { + "id": { "type": "integer", "description": "HN item ID." } + }, + "required": ["id"] + }, + "endpointMapping": { "method": "GET", "path": "/item/{id}.json" } + }, + { + "name": "hackernews_get_user", + "description": "Fetch user profile by case-sensitive username: returns `id`, `created`, `karma`, `about`, `submitted` (array of recent item IDs).", + "parameters": { + "type": "object", + "properties": { + "username": { "type": "string", "description": "HN username, case-sensitive." } + }, + "required": ["username"] + }, + "endpointMapping": { "method": "GET", "path": "/user/{username}.json" } + } + ] +} diff --git a/packages/backend/src/adapters/intl/hackernews.live.spec.ts b/packages/backend/src/adapters/intl/hackernews.live.spec.ts new file mode 100644 index 0000000..64bb033 --- /dev/null +++ b/packages/backend/src/adapters/intl/hackernews.live.spec.ts @@ -0,0 +1,7 @@ +import * as adapter from './hackernews.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string } }; +describe('hackernews adapter — static spec conformance', () => { + it('hacker-news.firebaseio.com/v0 base URL', () => + expect(a.connector.baseUrl).toBe('https://hacker-news.firebaseio.com/v0')); + it('no auth (public API)', () => expect(a.connector.authType).toBe('NONE')); +}); diff --git a/packages/backend/src/adapters/intl/harvest.json b/packages/backend/src/adapters/intl/harvest.json new file mode 100644 index 0000000..497b0a9 --- /dev/null +++ b/packages/backend/src/adapters/intl/harvest.json @@ -0,0 +1,260 @@ +{ + "slug": "harvest", + "name": "Harvest", + "description": "Manage Harvest (time tracking, projects, clients, invoices, expenses) from any AI agent. 10 tools, Bearer auth with account-ID header.", + "instructions": "This connector wraps the Harvest API v2 (api.harvestapp.com/v2).\n\n**Setup**:\n1. Log into https://id.getharvest.com → bottom-left avatar → **Developers → Create new personal access token**.\n2. Copy the **token** and the **Account ID** shown on the same page (a 7-digit number like `1234567`).\n3. Set both: `HARVEST_ACCESS_TOKEN` and `HARVEST_ACCOUNT_ID`.\n\n**Authentication**: every request requires BOTH:\n- `Authorization: Bearer ${HARVEST_ACCESS_TOKEN}`\n- `Harvest-Account-Id: ${HARVEST_ACCOUNT_ID}`\n- A `User-Agent` header (Harvest API requires it — the adapter sets `AnythingMCP (support@anythingmcp.com)`).\n\nMissing the account header returns `401 Unauthorized` even with a valid token — confusing but documented.\n\n**Hierarchy**: `Client → Project → Task assignment → Time entry`. To log time, you need the project ID + task ID. List projects to find IDs.\n\n**Dates** are ISO 8601 (`2026-05-20`). `spent_date` on a time entry is required.\n\n**Pagination**: `page` (1-based) + `per_page` (max 100). Total in `total_pages`.\n\n**Rate limits**: 100 req / 15s per account; 1k req / 15s globally per token. 429 returns no Retry-After — back off 15s.\n\n**Out of scope here**: invoice line CRUD beyond list/get, estimates, contacts, expenses categories management, recurring schedules.", + "region": "intl", + "category": "time-tracking", + "icon": "harvest", + "docsUrl": "https://help.getharvest.com/api-v2/", + "requiredEnvVars": ["HARVEST_ACCESS_TOKEN", "HARVEST_ACCOUNT_ID"], + "connector": { + "name": "Harvest API v2", + "type": "REST", + "baseUrl": "https://api.harvestapp.com/v2", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{HARVEST_ACCESS_TOKEN}}" + }, + "headers": { + "Harvest-Account-Id": "{{HARVEST_ACCOUNT_ID}}", + "User-Agent": "AnythingMCP (support@anythingmcp.com)" + } + }, + "tools": [ + { + "name": "harvest_who_am_i", + "description": "Return the currently authenticated user: id, first_name, last_name, email, is_admin, default_hourly_rate, weekly_capacity.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/users/me" } + }, + { + "name": "harvest_list_clients", + "description": "List clients. Each has id, name, is_active, address, currency.", + "parameters": { + "type": "object", + "properties": { + "is_active": { "type": "boolean", "description": "Filter to active only." }, + "updated_since": { "type": "string", "description": "ISO 8601 datetime — only clients updated after this." }, + "page": { "type": "integer", "description": "1-based page index." }, + "per_page": { "type": "integer", "description": "Max 100." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/clients", + "queryParams": { + "is_active": "$is_active", + "updated_since": "$updated_since", + "page": "$page", + "per_page": "$per_page" + } + } + }, + { + "name": "harvest_list_projects", + "description": "List projects. Most useful filter: `client_id` to drill into one client's projects. Returns id, name, code, is_active, is_billable, budget, hourly_rate, client info.", + "parameters": { + "type": "object", + "properties": { + "client_id": { "type": "integer", "description": "Filter to one client's projects." }, + "is_active": { "type": "boolean", "description": "Filter to active only." }, + "page": { "type": "integer", "description": "1-based." }, + "per_page": { "type": "integer", "description": "Max 100." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/projects", + "queryParams": { + "client_id": "$client_id", + "is_active": "$is_active", + "page": "$page", + "per_page": "$per_page" + } + } + }, + { + "name": "harvest_list_tasks", + "description": "List task templates (the canonical Harvest 'task' types: 'Programming', 'Design', etc.) that can be assigned to projects.", + "parameters": { + "type": "object", + "properties": { + "is_active": { "type": "boolean", "description": "Filter to active only." }, + "page": { "type": "integer", "description": "1-based." }, + "per_page": { "type": "integer", "description": "Max 100." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/tasks", + "queryParams": { "is_active": "$is_active", "page": "$page", "per_page": "$per_page" } + } + }, + { + "name": "harvest_list_time_entries", + "description": "List time entries. Most useful filters: `from`/`to` for date range, `user_id` for one user, `project_id` for one project. Returns each entry with hours, notes, billable status, dates, project, task.", + "parameters": { + "type": "object", + "properties": { + "user_id": { "type": "integer", "description": "Filter to one user." }, + "client_id": { "type": "integer", "description": "Filter to one client." }, + "project_id": { "type": "integer", "description": "Filter to one project." }, + "task_id": { "type": "integer", "description": "Filter to one task." }, + "from": { "type": "string", "description": "Start date (ISO 8601 YYYY-MM-DD)." }, + "to": { "type": "string", "description": "End date (YYYY-MM-DD)." }, + "is_billed": { "type": "boolean", "description": "Filter by billed status." }, + "is_running": { "type": "boolean", "description": "Only currently-running timers." }, + "page": { "type": "integer", "description": "1-based." }, + "per_page": { "type": "integer", "description": "Max 100." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/time_entries", + "queryParams": { + "user_id": "$user_id", + "client_id": "$client_id", + "project_id": "$project_id", + "task_id": "$task_id", + "from": "$from", + "to": "$to", + "is_billed": "$is_billed", + "is_running": "$is_running", + "page": "$page", + "per_page": "$per_page" + } + } + }, + { + "name": "harvest_create_time_entry", + "description": "Log time on a project + task. EITHER supply `hours` for a manual entry OR omit hours to start a running timer (you'll need to stop it later with harvest_stop_timer).", + "parameters": { + "type": "object", + "properties": { + "project_id": { "type": "integer", "description": "Project ID." }, + "task_id": { "type": "integer", "description": "Task ID assigned to this project." }, + "spent_date": { "type": "string", "description": "Date worked (YYYY-MM-DD)." }, + "hours": { "type": "number", "description": "Hours worked. Omit to start a running timer." }, + "notes": { "type": "string", "description": "Optional notes." }, + "user_id": { "type": "integer", "description": "Defaults to current user; admins can log for others." }, + "external_reference": { "type": "object", "description": "External system reference {id, group_id, account_id, permalink, service, service_icon_url}." } + }, + "required": ["project_id", "task_id", "spent_date"] + }, + "endpointMapping": { + "method": "POST", + "path": "/time_entries", + "bodyMapping": { + "project_id": "$project_id", + "task_id": "$task_id", + "spent_date": "$spent_date", + "hours": "$hours", + "notes": "$notes", + "user_id": "$user_id", + "external_reference": "$external_reference" + } + } + }, + { + "name": "harvest_update_time_entry", + "description": "Update fields on an existing time entry (notes, hours, project, task).", + "parameters": { + "type": "object", + "properties": { + "time_entry_id": { "type": "integer", "description": "Time entry ID." }, + "hours": { "type": "number", "description": "New hours." }, + "notes": { "type": "string", "description": "New notes." }, + "project_id": { "type": "integer", "description": "Move to a different project." }, + "task_id": { "type": "integer", "description": "Move to a different task." } + }, + "required": ["time_entry_id"] + }, + "endpointMapping": { + "method": "PATCH", + "path": "/time_entries/{time_entry_id}", + "bodyMapping": { + "hours": "$hours", + "notes": "$notes", + "project_id": "$project_id", + "task_id": "$task_id" + } + } + }, + { + "name": "harvest_stop_timer", + "description": "Stop a currently-running time entry timer.", + "parameters": { + "type": "object", + "properties": { + "time_entry_id": { "type": "integer", "description": "Running time entry ID." } + }, + "required": ["time_entry_id"] + }, + "endpointMapping": { + "method": "PATCH", + "path": "/time_entries/{time_entry_id}/stop" + } + }, + { + "name": "harvest_list_invoices", + "description": "List invoices. Filter by client_id, state ('draft', 'open', 'paid', 'closed'), date range. Returns each invoice's number, amount, due_date, paid_date, currency, line items count.", + "parameters": { + "type": "object", + "properties": { + "client_id": { "type": "integer", "description": "Filter to one client." }, + "project_id": { "type": "integer", "description": "Filter to one project." }, + "state": { "type": "string", "description": "draft, open, paid, closed." }, + "from": { "type": "string", "description": "Issued from (YYYY-MM-DD)." }, + "to": { "type": "string", "description": "Issued to (YYYY-MM-DD)." }, + "page": { "type": "integer", "description": "1-based." }, + "per_page": { "type": "integer", "description": "Max 100." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/invoices", + "queryParams": { + "client_id": "$client_id", + "project_id": "$project_id", + "state": "$state", + "from": "$from", + "to": "$to", + "page": "$page", + "per_page": "$per_page" + } + } + }, + { + "name": "harvest_list_expenses", + "description": "List expenses. Filter by client, project, user, date range. Returns each expense's amount, units, billable status, receipt URL.", + "parameters": { + "type": "object", + "properties": { + "user_id": { "type": "integer", "description": "Filter to one user." }, + "client_id": { "type": "integer", "description": "Filter to one client." }, + "project_id": { "type": "integer", "description": "Filter to one project." }, + "is_billed": { "type": "boolean", "description": "Filter by billed status." }, + "from": { "type": "string", "description": "Spent from (YYYY-MM-DD)." }, + "to": { "type": "string", "description": "Spent to (YYYY-MM-DD)." }, + "page": { "type": "integer", "description": "1-based." }, + "per_page": { "type": "integer", "description": "Max 100." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/expenses", + "queryParams": { + "user_id": "$user_id", + "client_id": "$client_id", + "project_id": "$project_id", + "is_billed": "$is_billed", + "from": "$from", + "to": "$to", + "page": "$page", + "per_page": "$per_page" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/harvest.live.spec.ts b/packages/backend/src/adapters/intl/harvest.live.spec.ts new file mode 100644 index 0000000..f0d5c58 --- /dev/null +++ b/packages/backend/src/adapters/intl/harvest.live.spec.ts @@ -0,0 +1,13 @@ +import * as adapter from './harvest.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; headers: Record }; +}; +describe('harvest adapter — static spec conformance', () => { + it('api.harvestapp.com/v2 base URL', () => + expect(a.connector.baseUrl).toBe('https://api.harvestapp.com/v2')); + it('Bearer auth', () => expect(a.connector.authType).toBe('BEARER_TOKEN')); + it('Harvest-Account-Id + User-Agent headers required', () => { + expect(a.connector.headers['Harvest-Account-Id']).toBeDefined(); + expect(a.connector.headers['User-Agent']).toBeDefined(); + }); +}); diff --git a/packages/backend/src/adapters/intl/mastodon.json b/packages/backend/src/adapters/intl/mastodon.json new file mode 100644 index 0000000..08cee3f --- /dev/null +++ b/packages/backend/src/adapters/intl/mastodon.json @@ -0,0 +1,199 @@ +{ + "slug": "mastodon", + "name": "Mastodon", + "description": "Read & post on any Mastodon instance from any AI agent. 10 tools, Bearer token auth.", + "instructions": "This connector wraps the Mastodon REST API (any instance — set baseUrl to your instance).\n\n**Setup**:\n1. Pick your instance (e.g. `https://mastodon.social`, `https://fosstodon.org`, your self-hosted one).\n2. Log in → **Preferences → Development → New application**.\n3. Name it (e.g. 'mcp'). Scopes: `read`, `write`, `follow`, `push` — tick whatever your use case needs (don't over-grant).\n4. Save → copy the **Your access token** value (shown only once). This is a personal access token tied to your account.\n5. Set `MASTODON_INSTANCE_URL` (e.g. `https://mastodon.social`) and `MASTODON_ACCESS_TOKEN`.\n\n**Authentication**: `Authorization: Bearer ${MASTODON_ACCESS_TOKEN}` on every request.\n\n**Per-instance API**: Mastodon is federated — every instance hosts its own API. baseUrl is your home instance. To read posts from other instances, the API will federate transparently when you provide a status ID OR you can use the public timeline endpoints.\n\n**IDs are per-instance**: a status ID from mastodon.social is NOT the same as the same status on fosstodon.org (each instance assigns its own internal ID even if the status was federated). To look up cross-instance: use `mastodon_search` with the full URL.\n\n**Post (toot) limits**: most instances allow 500 characters; some configure more. Polls have separate rules.\n\n**Pagination**: cursor-based via Link headers (`max_id`, `min_id`, `since_id`). The adapter exposes `max_id` / `since_id` as parameters.\n\n**Visibility**: when posting, `visibility` is one of `public`, `unlisted`, `private` (followers-only), `direct` (mentioned-only). Default per-account.\n\n**Rate limits**: 300 req / 5min per token on most instances. 429 with `X-RateLimit-Reset` header.\n\n**Out of scope here**: image/video upload multipart (use the dedicated media endpoint with a separate flow), instance admin, lists CRUD, push subscriptions.", + "region": "intl", + "category": "social", + "icon": "mastodon", + "docsUrl": "https://docs.joinmastodon.org/api/", + "requiredEnvVars": ["MASTODON_INSTANCE_URL", "MASTODON_ACCESS_TOKEN"], + "connector": { + "name": "Mastodon REST API", + "type": "REST", + "baseUrl": "{{MASTODON_INSTANCE_URL}}", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{MASTODON_ACCESS_TOKEN}}" + } + }, + "tools": [ + { + "name": "mastodon_verify_credentials", + "description": "Return the authenticated account: id, username, acct (full handle), display_name, note, avatar, statuses_count, followers_count, following_count.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/api/v1/accounts/verify_credentials" } + }, + { + "name": "mastodon_get_account", + "description": "Fetch a public account by ID. For lookup by handle, use mastodon_search.", + "parameters": { + "type": "object", + "properties": { + "account_id": { "type": "string", "description": "Account ID." } + }, + "required": ["account_id"] + }, + "endpointMapping": { "method": "GET", "path": "/api/v1/accounts/{account_id}" } + }, + { + "name": "mastodon_account_statuses", + "description": "Get a user's recent statuses (toots).", + "parameters": { + "type": "object", + "properties": { + "account_id": { "type": "string", "description": "Account ID." }, + "limit": { "type": "integer", "description": "Max 40." }, + "max_id": { "type": "string", "description": "Pagination — return statuses older than this id." }, + "since_id": { "type": "string", "description": "Pagination — return statuses newer than this id." }, + "exclude_replies": { "type": "boolean", "description": "Skip replies." }, + "exclude_reblogs": { "type": "boolean", "description": "Skip boosts." }, + "only_media": { "type": "boolean", "description": "Only statuses with attachments." } + }, + "required": ["account_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v1/accounts/{account_id}/statuses", + "queryParams": { + "limit": "$limit", + "max_id": "$max_id", + "since_id": "$since_id", + "exclude_replies": "$exclude_replies", + "exclude_reblogs": "$exclude_reblogs", + "only_media": "$only_media" + } + } + }, + { + "name": "mastodon_home_timeline", + "description": "Get the authenticated user's home timeline (their follows).", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Max 40." }, + "max_id": { "type": "string", "description": "Older than." }, + "since_id": { "type": "string", "description": "Newer than." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v1/timelines/home", + "queryParams": { "limit": "$limit", "max_id": "$max_id", "since_id": "$since_id" } + } + }, + { + "name": "mastodon_public_timeline", + "description": "Get the public timeline. `local=true` = only this instance's accounts; `remote=true` = only federated. Default: federated public.", + "parameters": { + "type": "object", + "properties": { + "local": { "type": "boolean", "description": "Only this instance." }, + "remote": { "type": "boolean", "description": "Only federated remote accounts." }, + "only_media": { "type": "boolean", "description": "Only statuses with media." }, + "limit": { "type": "integer", "description": "Max 40." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v1/timelines/public", + "queryParams": { + "local": "$local", + "remote": "$remote", + "only_media": "$only_media", + "limit": "$limit" + } + } + }, + { + "name": "mastodon_hashtag_timeline", + "description": "Get statuses tagged with a hashtag (no # prefix).", + "parameters": { + "type": "object", + "properties": { + "hashtag": { "type": "string", "description": "Tag, no '#'." }, + "local": { "type": "boolean", "description": "Only local accounts." }, + "limit": { "type": "integer", "description": "Max 40." }, + "max_id": { "type": "string", "description": "Older than." } + }, + "required": ["hashtag"] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v1/timelines/tag/{hashtag}", + "queryParams": { "local": "$local", "limit": "$limit", "max_id": "$max_id" } + } + }, + { + "name": "mastodon_search", + "description": "Full-text search across accounts, statuses and hashtags. Pass a full status URL in `q` to look up federated content by URL.", + "parameters": { + "type": "object", + "properties": { + "q": { "type": "string", "description": "Query text or URL." }, + "type": { "type": "string", "description": "Restrict: 'accounts', 'statuses', or 'hashtags'." }, + "resolve": { "type": "boolean", "description": "If true and `q` is a URL, fetch it from the remote instance." }, + "limit": { "type": "integer", "description": "Max 40." } + }, + "required": ["q"] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/search", + "queryParams": { "q": "$q", "type": "$type", "resolve": "$resolve", "limit": "$limit" } + } + }, + { + "name": "mastodon_get_status", + "description": "Fetch a single status by ID. Returns content, account, media, mentions, tags, replies/favourites/reblogs counts.", + "parameters": { + "type": "object", + "properties": { + "status_id": { "type": "string", "description": "Status ID (per-instance)." } + }, + "required": ["status_id"] + }, + "endpointMapping": { "method": "GET", "path": "/api/v1/statuses/{status_id}" } + }, + { + "name": "mastodon_post_status", + "description": "Post a new status (toot). `status` is the text; optionally include `in_reply_to_id` to thread.", + "parameters": { + "type": "object", + "properties": { + "status": { "type": "string", "description": "Status text. Most instances cap at 500 chars." }, + "in_reply_to_id": { "type": "string", "description": "ID of status this is a reply to." }, + "sensitive": { "type": "boolean", "description": "Mark media as sensitive (CW)." }, + "spoiler_text": { "type": "string", "description": "Content warning text (subject line)." }, + "visibility": { "type": "string", "description": "'public', 'unlisted', 'private', 'direct'." }, + "language": { "type": "string", "description": "ISO 639-1 language code." } + }, + "required": ["status"] + }, + "endpointMapping": { + "method": "POST", + "path": "/api/v1/statuses", + "bodyMapping": { + "status": "$status", + "in_reply_to_id": "$in_reply_to_id", + "sensitive": "$sensitive", + "spoiler_text": "$spoiler_text", + "visibility": "$visibility", + "language": "$language" + } + } + }, + { + "name": "mastodon_delete_status", + "description": "Delete one of your own statuses.", + "parameters": { + "type": "object", + "properties": { + "status_id": { "type": "string", "description": "Status ID." } + }, + "required": ["status_id"] + }, + "endpointMapping": { "method": "DELETE", "path": "/api/v1/statuses/{status_id}" } + } + ] +} diff --git a/packages/backend/src/adapters/intl/mastodon.live.spec.ts b/packages/backend/src/adapters/intl/mastodon.live.spec.ts new file mode 100644 index 0000000..f7de6ed --- /dev/null +++ b/packages/backend/src/adapters/intl/mastodon.live.spec.ts @@ -0,0 +1,8 @@ +import * as adapter from './mastodon.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string } }; +describe('mastodon adapter — static spec conformance', () => { + it('baseUrl is the per-instance placeholder', () => + expect(a.connector.baseUrl).toBe('{{MASTODON_INSTANCE_URL}}')); + it('Bearer auth (per-instance personal access token)', () => + expect(a.connector.authType).toBe('BEARER_TOKEN')); +}); diff --git a/packages/backend/src/adapters/intl/new-relic.json b/packages/backend/src/adapters/intl/new-relic.json new file mode 100644 index 0000000..005b74d --- /dev/null +++ b/packages/backend/src/adapters/intl/new-relic.json @@ -0,0 +1,159 @@ +{ + "slug": "new-relic", + "name": "New Relic", + "description": "Query New Relic (NRQL, alerts, incidents, dashboards, deployments, entities) from any AI agent via NerdGraph + REST. 7 tools, API-key auth.", + "instructions": "This connector wraps two New Relic APIs: the modern NerdGraph (GraphQL) at api.newrelic.com/graphql AND the legacy REST API for deployments (api.newrelic.com/v2).\n\n**Setup**:\n1. Log into https://one.newrelic.com → top-right avatar → **API keys**.\n2. Create a **User key** (type: USER, NOT 'License key' / 'Ingest key'). Scopes: account read at minimum; admin if you want to create deployments. Set `NEW_RELIC_USER_KEY`.\n3. Note your **Account ID** (visible in the URL or via the keys page). You'll pass it as a parameter to most tools.\n4. **EU region**: if your org is on the EU data centre, change baseUrl to `https://api.eu.newrelic.com`.\n\n**Authentication**: every request sends `Api-Key: ${NEW_RELIC_USER_KEY}` as a header.\n\n**NerdGraph is the modern API**: most data lives in GraphQL now. The `new-relic_run_nrql` tool wraps the common pattern — pass an account ID + NRQL string, get rows back. For arbitrary queries use `new-relic_graphql_query` (raw GraphQL passthrough).\n\n**NRQL = New Relic Query Language**: SQL-like — `SELECT count(*) FROM Transaction WHERE appName = 'web' SINCE 1 hour ago FACET name`. Use FACET for grouping, TIMESERIES for time-bucketed data, SINCE / UNTIL for windows.\n\n**Time windows**: NRQL uses natural language — `SINCE 30 minutes ago`, `SINCE '2025-01-01' UNTIL '2025-01-02'`.\n\n**Rate limits**: NerdGraph allows 3k req/min per user key; NRQL queries are also gated by a per-account query budget.\n\n**Out of scope here**: agent installation, Synthetics test CRUD, full Alert policy/condition CRUD beyond list (use the dedicated Terraform provider).", + "region": "intl", + "category": "monitoring", + "icon": "new-relic", + "docsUrl": "https://docs.newrelic.com/docs/apis/nerdgraph/get-started/introduction-new-relic-nerdgraph/", + "requiredEnvVars": ["NEW_RELIC_USER_KEY"], + "connector": { + "name": "New Relic NerdGraph + REST", + "type": "REST", + "baseUrl": "https://api.newrelic.com", + "authType": "API_KEY", + "authConfig": { + "headerName": "Api-Key", + "apiKey": "{{NEW_RELIC_USER_KEY}}" + } + }, + "tools": [ + { + "name": "new_relic_run_nrql", + "description": "Run an NRQL (New Relic Query Language) query against an account. Wraps the NerdGraph actor.account.nrql.results pattern — much simpler than authoring raw GraphQL. Use this for 'how many errors in the last hour by service' etc.", + "parameters": { + "type": "object", + "properties": { + "account_id": { "type": "integer", "description": "New Relic account ID." }, + "nrql": { "type": "string", "description": "NRQL query, e.g. 'SELECT count(*) FROM Transaction SINCE 1 hour ago FACET appName'." } + }, + "required": ["account_id", "nrql"] + }, + "endpointMapping": { + "method": "POST", + "path": "/graphql", + "bodyMapping": { + "query": "query($accountId: Int!, $nrql: Nrql!) { actor { account(id: $accountId) { nrql(query: $nrql) { results metadata { facets } } } } }", + "variables": { "accountId": "$account_id", "nrql": "$nrql" } + } + } + }, + { + "name": "new_relic_graphql_query", + "description": "Run an arbitrary NerdGraph (GraphQL) query/mutation. Use this when run_nrql isn't enough — for entity search, dashboards, workloads, etc.", + "parameters": { + "type": "object", + "properties": { + "query": { "type": "string", "description": "GraphQL document." }, + "variables": { "type": "object", "description": "Variables map." } + }, + "required": ["query"] + }, + "endpointMapping": { + "method": "POST", + "path": "/graphql", + "bodyMapping": { "query": "$query", "variables": "$variables" } + } + }, + { + "name": "new_relic_search_entities", + "description": "Find entities (services, hosts, browsers, mobile apps, dashboards, workloads) by name + domain + type. Returns entity GUID, name, accountId, alertSeverity, reporting status.", + "parameters": { + "type": "object", + "properties": { + "query_string": { "type": "string", "description": "Entity search query, e.g. \"name LIKE 'api%' AND domain = 'APM'\"." } + }, + "required": ["query_string"] + }, + "endpointMapping": { + "method": "POST", + "path": "/graphql", + "bodyMapping": { + "query": "query($q: String!) { actor { entitySearch(query: $q) { results { entities { guid name domain type alertSeverity reporting accountId } } count } } }", + "variables": { "q": "$query_string" } + } + } + }, + { + "name": "new_relic_get_entity", + "description": "Fetch an entity by GUID — returns the full entity payload (golden metrics, golden tags, recent issues for APM apps).", + "parameters": { + "type": "object", + "properties": { + "guid": { "type": "string", "description": "Entity GUID from entitySearch." } + }, + "required": ["guid"] + }, + "endpointMapping": { + "method": "POST", + "path": "/graphql", + "bodyMapping": { + "query": "query($guid: EntityGuid!) { actor { entity(guid: $guid) { guid name domain type tags { key values } alertSeverity ... on ApmApplicationEntity { goldenMetrics { metrics { name } } } } } }", + "variables": { "guid": "$guid" } + } + } + }, + { + "name": "new_relic_list_deployments", + "description": "List recent deployments recorded for an APM application via the legacy REST API. Returns each deployment's id, revision, description, user, timestamp.", + "parameters": { + "type": "object", + "properties": { + "application_id": { "type": "integer", "description": "Legacy APM application numeric ID (NOT entity GUID)." } + }, + "required": ["application_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/v2/applications/{application_id}/deployments.json" + } + }, + { + "name": "new_relic_create_deployment", + "description": "Record a deployment marker on an APM application's chart. Useful for correlating regressions with releases.", + "parameters": { + "type": "object", + "properties": { + "application_id": { "type": "integer", "description": "Legacy APM application numeric ID." }, + "revision": { "type": "string", "description": "Version / git SHA / build number." }, + "description": { "type": "string", "description": "What changed." }, + "user": { "type": "string", "description": "Who deployed (free-text)." }, + "changelog": { "type": "string", "description": "Optional release notes." } + }, + "required": ["application_id", "revision"] + }, + "endpointMapping": { + "method": "POST", + "path": "/v2/applications/{application_id}/deployments.json", + "bodyMapping": { + "deployment": { + "revision": "$revision", + "description": "$description", + "user": "$user", + "changelog": "$changelog" + } + } + } + }, + { + "name": "new_relic_recent_incidents", + "description": "List recent open incidents for an account from the AI/AIops issue stream.", + "parameters": { + "type": "object", + "properties": { + "account_id": { "type": "integer", "description": "Account ID." } + }, + "required": ["account_id"] + }, + "endpointMapping": { + "method": "POST", + "path": "/graphql", + "bodyMapping": { + "query": "query($accountId: Int!) { actor { account(id: $accountId) { aiIssues { issues(filter: { states: [ACTIVE] }) { issues { issueId title priority createdAt entityNames } } } } } }", + "variables": { "accountId": "$account_id" } + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/new-relic.live.spec.ts b/packages/backend/src/adapters/intl/new-relic.live.spec.ts new file mode 100644 index 0000000..d14373e --- /dev/null +++ b/packages/backend/src/adapters/intl/new-relic.live.spec.ts @@ -0,0 +1,12 @@ +import * as adapter from './new-relic.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: { headerName: string } }; +}; +describe('new-relic adapter — static spec conformance', () => { + it('api.newrelic.com base URL (US default)', () => + expect(a.connector.baseUrl).toBe('https://api.newrelic.com')); + it('Api-Key header auth', () => { + expect(a.connector.authType).toBe('API_KEY'); + expect(a.connector.authConfig.headerName).toBe('Api-Key'); + }); +}); diff --git a/packages/backend/src/adapters/intl/newsapi.json b/packages/backend/src/adapters/intl/newsapi.json new file mode 100644 index 0000000..d8e4bef --- /dev/null +++ b/packages/backend/src/adapters/intl/newsapi.json @@ -0,0 +1,104 @@ +{ + "slug": "newsapi", + "name": "NewsAPI", + "description": "Search worldwide news headlines, sources and articles from newsapi.org via any AI agent. 3 tools, API-key auth.", + "instructions": "This connector wraps the NewsAPI v2 REST API (newsapi.org).\n\n**Setup**:\n1. Register at https://newsapi.org/register for a free Developer key.\n2. Set `NEWSAPI_API_KEY`.\n\n**Authentication**: NewsAPI accepts the key as either the `X-Api-Key` header (used by this adapter) OR an `apiKey` query string. We use the header so it doesn't appear in URL logs.\n\n**Plan limits — IMPORTANT**:\n- Developer (free): 100 requests / day, articles delayed by 24h, no commercial use, results capped at 100 per query.\n- Paid plans: live articles, no daily cap, full historical archive.\n\n**Three endpoints**:\n- `everything` — full archive search, the most powerful. Filter by `q`, `sources`, `domains`, `from`/`to` date, `language`, `sortBy`.\n- `top-headlines` — breaking news, filtered by `country`, `category`, `sources`, or `q`. You CANNOT mix `country`/`category` with `sources` in the same call.\n- `top-headlines/sources` — list all sources, optional filters by `category`, `language`, `country`.\n\n**Query syntax**: `q` supports `AND`, `OR`, `NOT`, exact-phrase double quotes, and grouping with parentheses. Example: `q=(apple AND iphone) NOT macbook`.\n\n**Languages**: ISO-639-1 codes — `en`, `it`, `de`, `es`, `fr`, ... See docs for full list.\n\n**Out of scope here**: NewsAPI does NOT expose article full text — only `title`, `description`, `content` (truncated to ~200 chars). For full text you must fetch the `url` separately.", + "region": "intl", + "category": "data", + "icon": "newsapi", + "docsUrl": "https://newsapi.org/docs", + "requiredEnvVars": ["NEWSAPI_API_KEY"], + "connector": { + "name": "NewsAPI v2", + "type": "REST", + "baseUrl": "https://newsapi.org/v2", + "authType": "API_KEY", + "authConfig": { + "headerName": "X-Api-Key", + "apiKey": "{{NEWSAPI_API_KEY}}" + } + }, + "tools": [ + { + "name": "newsapi_everything", + "description": "Full-archive search across millions of articles from 80k+ sources. Use this when the user wants 'articles about X' or 'news mentioning Y this week'. Requires at least one of `q`, `qInTitle`, `sources`, or `domains`.", + "parameters": { + "type": "object", + "properties": { + "q": { "type": "string", "description": "Keywords/phrases. Supports AND/OR/NOT, quotes, parentheses." }, + "qInTitle": { "type": "string", "description": "Same as `q` but only matches against the article title." }, + "sources": { "type": "string", "description": "Comma-separated source IDs (e.g. 'bbc-news,the-verge'). Find IDs via newsapi_sources." }, + "domains": { "type": "string", "description": "Comma-separated domains (e.g. 'bbc.co.uk,nytimes.com')." }, + "excludeDomains": { "type": "string", "description": "Comma-separated domains to EXCLUDE." }, + "from": { "type": "string", "description": "ISO 8601 date or datetime. Oldest article date." }, + "to": { "type": "string", "description": "ISO 8601 date or datetime. Newest article date." }, + "language": { "type": "string", "description": "ISO-639-1: en, it, de, es, fr, ..." }, + "sortBy": { "type": "string", "description": "'relevancy', 'popularity', 'publishedAt'. Default 'publishedAt'." }, + "pageSize": { "type": "integer", "description": "Results per page (1-100). Default 100." }, + "page": { "type": "integer", "description": "1-based page index." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/everything", + "queryParams": { + "q": "$q", + "qInTitle": "$qInTitle", + "sources": "$sources", + "domains": "$domains", + "excludeDomains": "$excludeDomains", + "from": "$from", + "to": "$to", + "language": "$language", + "sortBy": "$sortBy", + "pageSize": "$pageSize", + "page": "$page" + } + } + }, + { + "name": "newsapi_top_headlines", + "description": "Breaking news headlines. Filter by `country` + `category` for a country front-page, OR by `sources` for specific outlets, OR by `q` for a topic. You CANNOT combine `country`/`category` with `sources` in one call.", + "parameters": { + "type": "object", + "properties": { + "country": { "type": "string", "description": "2-letter ISO 3166-1 (e.g. 'us', 'it', 'de'). Cannot be used with `sources`." }, + "category": { "type": "string", "description": "business, entertainment, general, health, science, sports, technology. Cannot be used with `sources`." }, + "sources": { "type": "string", "description": "Comma-separated source IDs. Cannot be used with `country`/`category`." }, + "q": { "type": "string", "description": "Keywords/phrases." }, + "pageSize": { "type": "integer", "description": "1-100." }, + "page": { "type": "integer", "description": "1-based." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/top-headlines", + "queryParams": { + "country": "$country", + "category": "$category", + "sources": "$sources", + "q": "$q", + "pageSize": "$pageSize", + "page": "$page" + } + } + }, + { + "name": "newsapi_sources", + "description": "List all news sources NewsAPI tracks. Returns each source's id (used by other endpoints), name, description, url, category, language, country.", + "parameters": { + "type": "object", + "properties": { + "category": { "type": "string", "description": "Filter by category." }, + "language": { "type": "string", "description": "ISO-639-1." }, + "country": { "type": "string", "description": "2-letter ISO 3166-1." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/top-headlines/sources", + "queryParams": { "category": "$category", "language": "$language", "country": "$country" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/newsapi.live.spec.ts b/packages/backend/src/adapters/intl/newsapi.live.spec.ts new file mode 100644 index 0000000..4b2cd12 --- /dev/null +++ b/packages/backend/src/adapters/intl/newsapi.live.spec.ts @@ -0,0 +1,12 @@ +import * as adapter from './newsapi.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: { headerName: string } }; +}; +describe('newsapi adapter — static spec conformance', () => { + it('newsapi.org/v2 base URL', () => + expect(a.connector.baseUrl).toBe('https://newsapi.org/v2')); + it('X-Api-Key header auth', () => { + expect(a.connector.authType).toBe('API_KEY'); + expect(a.connector.authConfig.headerName).toBe('X-Api-Key'); + }); +}); diff --git a/packages/backend/src/adapters/intl/openweather.json b/packages/backend/src/adapters/intl/openweather.json new file mode 100644 index 0000000..55a6da7 --- /dev/null +++ b/packages/backend/src/adapters/intl/openweather.json @@ -0,0 +1,130 @@ +{ + "slug": "openweather", + "name": "OpenWeather", + "description": "Query OpenWeather (current weather, forecasts, air quality, geocoding) from any AI agent. 6 tools, API-key auth.", + "instructions": "This connector wraps the OpenWeather REST API (api.openweathermap.org).\n\n**Setup**:\n1. Sign up at https://openweathermap.org/users/sign_up (free tier: 60 calls/min, 1M/month).\n2. Go to https://home.openweathermap.org/api_keys → copy the default key OR create a new one labelled e.g. `mcp`.\n3. Wait ~10 minutes — new keys take time to activate on the free tier.\n4. Set `OPENWEATHER_API_KEY`.\n\n**Authentication**: every request takes `appid=${OPENWEATHER_API_KEY}` as a query string. The adapter injects it automatically — never include it in tool arguments.\n\n**Coordinates first**: most endpoints want `lat`/`lon`, not city names. Use `openweather_geocode_city` first to translate `London,GB` into coordinates, then call the weather endpoints with the coordinates. The Geocoding API is free and unlimited within the regular rate limit.\n\n**Units**: pass `units=metric` (Celsius, m/s) or `units=imperial` (Fahrenheit, mph). Default (`standard`) returns Kelvin and m/s — usually NOT what you want.\n\n**Language**: pass `lang=it` / `de` / `en` for localized weather descriptions. The numeric data is always identical, only the human-readable `weather[0].description` string changes.\n\n**Forecast endpoints**: the free tier exposes the 5-day/3-hour forecast (`/data/2.5/forecast`). The newer One Call 3.0 API (hourly + daily + minutely) requires a paid subscription — gated separately in the tool descriptions.\n\n**Rate limits**: 60 calls/min on free tier; tighter limits on burst. On 429, exponential backoff. Errors come back as `{cod, message}` with HTTP 401 for bad key, 404 for unknown city, 429 for rate-limit.\n\n**Out of scope here**: historical data (paid), weather maps tiles (different host, image returns), severe weather alerts (paid).", + "region": "intl", + "category": "data", + "icon": "openweather", + "docsUrl": "https://openweathermap.org/api", + "requiredEnvVars": ["OPENWEATHER_API_KEY"], + "connector": { + "name": "OpenWeather API", + "type": "REST", + "baseUrl": "https://api.openweathermap.org", + "authType": "QUERY_AUTH", + "authConfig": { + "appid": "{{OPENWEATHER_API_KEY}}" + } + }, + "tools": [ + { + "name": "openweather_geocode_city", + "description": "Translate a city name (optionally with state and country code) into latitude/longitude. Always run this first when the user asks about a city — every other tool wants lat/lon, not city names. Returns up to `limit` candidates; the first is usually the most populous.", + "parameters": { + "type": "object", + "properties": { + "q": { "type": "string", "description": "Location query, e.g. 'Rome,IT' or 'Springfield,IL,US' (city,state,country)." }, + "limit": { "type": "integer", "description": "Max results (1-5). Default 1." } + }, + "required": ["q"] + }, + "endpointMapping": { + "method": "GET", + "path": "/geo/1.0/direct", + "queryParams": { "q": "$q", "limit": "$limit" } + } + }, + { + "name": "openweather_reverse_geocode", + "description": "Translate latitude/longitude back into a list of nearby place names. Useful when you have GPS coordinates from another tool and need a human-readable label.", + "parameters": { + "type": "object", + "properties": { + "lat": { "type": "number", "description": "Latitude." }, + "lon": { "type": "number", "description": "Longitude." }, + "limit": { "type": "integer", "description": "Max results (1-5). Default 1." } + }, + "required": ["lat", "lon"] + }, + "endpointMapping": { + "method": "GET", + "path": "/geo/1.0/reverse", + "queryParams": { "lat": "$lat", "lon": "$lon", "limit": "$limit" } + } + }, + { + "name": "openweather_current_weather", + "description": "Current weather at given coordinates: temperature, humidity, wind, conditions, sunrise/sunset. Snapshot only — for forecasts use openweather_forecast_5day.", + "parameters": { + "type": "object", + "properties": { + "lat": { "type": "number", "description": "Latitude." }, + "lon": { "type": "number", "description": "Longitude." }, + "units": { "type": "string", "description": "'metric' (°C, m/s), 'imperial' (°F, mph), or 'standard' (K, m/s)." }, + "lang": { "type": "string", "description": "ISO 639-1 code for localized descriptions (it, de, en, es, ...)." } + }, + "required": ["lat", "lon"] + }, + "endpointMapping": { + "method": "GET", + "path": "/data/2.5/weather", + "queryParams": { "lat": "$lat", "lon": "$lon", "units": "$units", "lang": "$lang" } + } + }, + { + "name": "openweather_forecast_5day", + "description": "5-day weather forecast at 3-hour intervals. Returns a `list` of 40 entries; each has dt, temperature, weather[], wind, pop (precipitation probability). Use this for 'will it rain tomorrow' style questions on the free tier.", + "parameters": { + "type": "object", + "properties": { + "lat": { "type": "number", "description": "Latitude." }, + "lon": { "type": "number", "description": "Longitude." }, + "units": { "type": "string", "description": "'metric' / 'imperial' / 'standard'." }, + "lang": { "type": "string", "description": "Locale for descriptions." }, + "cnt": { "type": "integer", "description": "Limit number of 3-hour timestamps returned (1-40)." } + }, + "required": ["lat", "lon"] + }, + "endpointMapping": { + "method": "GET", + "path": "/data/2.5/forecast", + "queryParams": { "lat": "$lat", "lon": "$lon", "units": "$units", "lang": "$lang", "cnt": "$cnt" } + } + }, + { + "name": "openweather_air_pollution", + "description": "Current air-quality index (AQI 1-5) + concentrations of CO, NO, NO2, O3, SO2, PM2.5, PM10, NH3 at given coordinates.", + "parameters": { + "type": "object", + "properties": { + "lat": { "type": "number", "description": "Latitude." }, + "lon": { "type": "number", "description": "Longitude." } + }, + "required": ["lat", "lon"] + }, + "endpointMapping": { + "method": "GET", + "path": "/data/2.5/air_pollution", + "queryParams": { "lat": "$lat", "lon": "$lon" } + } + }, + { + "name": "openweather_air_pollution_forecast", + "description": "Hourly air-pollution forecast for the next ~4 days at given coordinates.", + "parameters": { + "type": "object", + "properties": { + "lat": { "type": "number", "description": "Latitude." }, + "lon": { "type": "number", "description": "Longitude." } + }, + "required": ["lat", "lon"] + }, + "endpointMapping": { + "method": "GET", + "path": "/data/2.5/air_pollution/forecast", + "queryParams": { "lat": "$lat", "lon": "$lon" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/openweather.live.spec.ts b/packages/backend/src/adapters/intl/openweather.live.spec.ts new file mode 100644 index 0000000..260e574 --- /dev/null +++ b/packages/backend/src/adapters/intl/openweather.live.spec.ts @@ -0,0 +1,19 @@ +import * as adapter from './openweather.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: Record }; + tools: Array<{ name: string; endpointMapping: { path: string } }>; +}; +describe('openweather adapter — static spec conformance', () => { + it('api.openweathermap.org base URL', () => + expect(a.connector.baseUrl).toBe('https://api.openweathermap.org')); + it('QUERY_AUTH on appid', () => { + expect(a.connector.authType).toBe('QUERY_AUTH'); + expect(Object.keys(a.connector.authConfig)).toContain('appid'); + }); + it('exposes the geocode tool first in the list', () => + expect(a.tools[0].name).toBe('openweather_geocode_city')); + it('weather endpoints use /data/2.5/', () => { + const weather = a.tools.filter((t) => t.endpointMapping.path.startsWith('/data/2.5/')); + expect(weather.length).toBeGreaterThanOrEqual(4); + }); +}); diff --git a/packages/backend/src/adapters/intl/toggl-track.json b/packages/backend/src/adapters/intl/toggl-track.json new file mode 100644 index 0000000..a67181f --- /dev/null +++ b/packages/backend/src/adapters/intl/toggl-track.json @@ -0,0 +1,175 @@ +{ + "slug": "toggl-track", + "name": "Toggl Track", + "description": "Manage Toggl Track (time entries, projects, clients, tags, workspaces) from any AI agent. 9 tools, basic-auth with API token.", + "instructions": "This connector wraps the Toggl Track API v9 (api.track.toggl.com/api/v9).\n\n**Setup**:\n1. Log into https://track.toggl.com → bottom-left avatar → **Profile settings** → scroll to **API Token** → reveal/copy.\n2. Set `TOGGL_API_TOKEN`.\n\n**Authentication**: HTTP Basic, with API token as the username and the literal string `api_token` as the password:\n `Authorization: Basic base64(TOKEN:api_token)`\nThe adapter sets this automatically via BASIC_AUTH (username=token, password=api_token). DO NOT pass real user/password — Toggl only accepts the token-pair.\n\n**Workspace IDs**: Toggl Track is workspace-scoped. Most endpoints need a `workspace_id`. Use `toggl_track_me` first — its response contains `default_workspace_id` and `workspaces[].id`.\n\n**Running timer**: Toggl allows exactly ONE running entry per user at a time. Starting a new one stops any prior. To start, POST a time entry with `duration: -1` and `start: `. To stop, PATCH the entry to set `stop: ` and `duration: `.\n\n**Dates**: ISO 8601 with timezone (`2026-05-20T14:30:00+00:00`). Toggl is strict about timezone — naive datetimes get rejected.\n\n**Rate limits**: 1 req/s per IP soft, burst tolerated. 429 with `Retry-After` for hard throttling.\n\n**Out of scope here**: Toggl Reports v3 (different host, paid plans get more — could be a sibling adapter), team management, billing.", + "region": "intl", + "category": "time-tracking", + "icon": "toggl-track", + "docsUrl": "https://engineering.toggl.com/docs/", + "requiredEnvVars": ["TOGGL_API_TOKEN"], + "connector": { + "name": "Toggl Track API v9", + "type": "REST", + "baseUrl": "https://api.track.toggl.com/api/v9", + "authType": "BASIC_AUTH", + "authConfig": { + "username": "{{TOGGL_API_TOKEN}}", + "password": "api_token" + } + }, + "tools": [ + { + "name": "toggl_track_me", + "description": "Get the authenticated user including default workspace ID, all workspaces, default project/tag. Use this first to find the workspace ID needed by other tools.", + "parameters": { + "type": "object", + "properties": { + "with_related_data": { "type": "boolean", "description": "If true, include projects/clients/tags inline (heavier response)." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/me", + "queryParams": { "with_related_data": "$with_related_data" } + } + }, + { + "name": "toggl_track_list_workspaces", + "description": "List workspaces the user belongs to.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/me/workspaces" } + }, + { + "name": "toggl_track_list_clients", + "description": "List clients in a workspace.", + "parameters": { + "type": "object", + "properties": { + "workspace_id": { "type": "integer", "description": "Workspace ID." } + }, + "required": ["workspace_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/workspaces/{workspace_id}/clients" + } + }, + { + "name": "toggl_track_list_projects", + "description": "List projects in a workspace. Returns id, name, client_id, color, billable, active, estimated_hours, hourly_rate.", + "parameters": { + "type": "object", + "properties": { + "workspace_id": { "type": "integer", "description": "Workspace ID." }, + "active": { "type": "boolean", "description": "Filter active." }, + "billable": { "type": "boolean", "description": "Filter billable." }, + "name": { "type": "string", "description": "Filter by name substring." } + }, + "required": ["workspace_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/workspaces/{workspace_id}/projects", + "queryParams": { + "active": "$active", + "billable": "$billable", + "name": "$name" + } + } + }, + { + "name": "toggl_track_get_current_entry", + "description": "Return the currently running time entry (if any), else null.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/me/time_entries/current" } + }, + { + "name": "toggl_track_list_time_entries", + "description": "List time entries for the current user. Filter by start/end date window. Returns each entry with id, description, project_id, billable, duration (seconds, negative for running), start/stop.", + "parameters": { + "type": "object", + "properties": { + "start_date": { "type": "string", "description": "ISO 8601 datetime or date." }, + "end_date": { "type": "string", "description": "ISO 8601 datetime or date." }, + "before": { "type": "string", "description": "ISO 8601: only entries with stop_time before this." }, + "since": { "type": "integer", "description": "UNIX timestamp; only entries modified after." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/me/time_entries", + "queryParams": { + "start_date": "$start_date", + "end_date": "$end_date", + "before": "$before", + "since": "$since" + } + } + }, + { + "name": "toggl_track_create_time_entry", + "description": "Create a new time entry. Pass `duration: -1` + `start` (ISO 8601) to start a running timer; pass positive duration (seconds) + start to log a completed entry.", + "parameters": { + "type": "object", + "properties": { + "workspace_id": { "type": "integer", "description": "Workspace ID." }, + "description": { "type": "string", "description": "What you're working on." }, + "project_id": { "type": "integer", "description": "Project ID (optional)." }, + "tags": { "type": "array", "description": "Array of tag name strings." }, + "start": { "type": "string", "description": "ISO 8601 datetime with timezone." }, + "duration": { "type": "integer", "description": "Seconds. -1 to start a running timer." }, + "billable": { "type": "boolean", "description": "Mark billable." }, + "created_with": { "type": "string", "description": "Client identifier (defaults to 'AnythingMCP')." } + }, + "required": ["workspace_id", "start", "duration"] + }, + "endpointMapping": { + "method": "POST", + "path": "/workspaces/{workspace_id}/time_entries", + "bodyMapping": { + "workspace_id": "$workspace_id", + "description": "$description", + "project_id": "$project_id", + "tags": "$tags", + "start": "$start", + "duration": "$duration", + "billable": "$billable", + "created_with": "$created_with" + } + } + }, + { + "name": "toggl_track_stop_time_entry", + "description": "Stop a running time entry. Sets stop=now and converts duration to positive seconds.", + "parameters": { + "type": "object", + "properties": { + "workspace_id": { "type": "integer", "description": "Workspace ID." }, + "time_entry_id": { "type": "integer", "description": "Time entry ID to stop." } + }, + "required": ["workspace_id", "time_entry_id"] + }, + "endpointMapping": { + "method": "PATCH", + "path": "/workspaces/{workspace_id}/time_entries/{time_entry_id}/stop" + } + }, + { + "name": "toggl_track_delete_time_entry", + "description": "Delete a time entry permanently.", + "parameters": { + "type": "object", + "properties": { + "workspace_id": { "type": "integer", "description": "Workspace ID." }, + "time_entry_id": { "type": "integer", "description": "Time entry ID." } + }, + "required": ["workspace_id", "time_entry_id"] + }, + "endpointMapping": { + "method": "DELETE", + "path": "/workspaces/{workspace_id}/time_entries/{time_entry_id}" + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/toggl-track.live.spec.ts b/packages/backend/src/adapters/intl/toggl-track.live.spec.ts new file mode 100644 index 0000000..8feab4a --- /dev/null +++ b/packages/backend/src/adapters/intl/toggl-track.live.spec.ts @@ -0,0 +1,12 @@ +import * as adapter from './toggl-track.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: { password: string } }; +}; +describe('toggl-track adapter — static spec conformance', () => { + it('api.track.toggl.com/api/v9 base URL', () => + expect(a.connector.baseUrl).toBe('https://api.track.toggl.com/api/v9')); + it('Basic auth with literal password "api_token"', () => { + expect(a.connector.authType).toBe('BASIC_AUTH'); + expect(a.connector.authConfig.password).toBe('api_token'); + }); +}); diff --git a/packages/backend/src/adapters/intl/uptime-robot.json b/packages/backend/src/adapters/intl/uptime-robot.json new file mode 100644 index 0000000..de5c187 --- /dev/null +++ b/packages/backend/src/adapters/intl/uptime-robot.json @@ -0,0 +1,194 @@ +{ + "slug": "uptime-robot", + "name": "UptimeRobot", + "description": "Manage UptimeRobot (uptime monitoring) monitors, alert contacts, maintenance windows, public status pages from any AI agent. 8 tools, API-key auth.", + "instructions": "This connector wraps the UptimeRobot API v2 (api.uptimerobot.com/v2).\n\n**Setup**:\n1. Sign in to https://uptimerobot.com → **My Settings → API Settings**.\n2. Generate the **Main API Key** (full access to your monitors) OR a per-monitor read-only key. Most users want the Main key.\n3. Set `UPTIMEROBOT_API_KEY`.\n\n**Authentication**: every request POSTs to /v2/* with `api_key=...` injected as a query parameter on the URL. The API accepts the key either in the URL or in the body — this adapter uses the URL so each tool's body remains clean form-encoded params. We also always send `format=json` so responses are JSON instead of XML.\n\n**Monitor types** (integer codes):\n- 1: HTTP(s)\n- 2: Keyword (HTTP body must/must-not contain string)\n- 3: Ping (ICMP)\n- 4: Port (TCP)\n- 5: Heartbeat (cron-style — your job pings UptimeRobot every N minutes)\n\n**Status codes** (integer):\n- 0: paused\n- 1: not checked yet\n- 2: up\n- 8: seems down\n- 9: down\n\n**Pagination**: `offset` (0-based) + `limit` (max 50). Total returned in the `pagination.total` field.\n\n**Rate limits**: 10 req/min per API key on the free plan; higher on paid. 429 has no Retry-After — back off 6s.\n\n**Out of scope here**: subscription management, agent / probe location selection (paid feature), white-label status page customization.", + "region": "intl", + "category": "monitoring", + "icon": "uptime-robot", + "docsUrl": "https://uptimerobot.com/api/", + "requiredEnvVars": ["UPTIMEROBOT_API_KEY"], + "connector": { + "name": "UptimeRobot API v2", + "type": "REST", + "baseUrl": "https://api.uptimerobot.com/v2", + "authType": "QUERY_AUTH", + "authConfig": { + "api_key": "{{UPTIMEROBOT_API_KEY}}", + "format": "json" + } + }, + "tools": [ + { + "name": "uptimerobot_get_account_details", + "description": "Returns account info: email, monitor_limit, monitor_interval, up_monitors, down_monitors, paused_monitors.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "POST", "path": "/getAccountDetails" } + }, + { + "name": "uptimerobot_get_monitors", + "description": "List monitors. Optional filters by id, status, search string. Use `logs=1` to include up/down event history; `response_times=1` for response-time series; both add latency.", + "parameters": { + "type": "object", + "properties": { + "monitors": { "type": "string", "description": "Comma-separated monitor IDs to fetch (default: all)." }, + "types": { "type": "string", "description": "Comma-separated type codes (1-5)." }, + "statuses": { "type": "string", "description": "Comma-separated status codes (0,1,2,8,9)." }, + "search": { "type": "string", "description": "Substring filter against friendly_name or url." }, + "logs": { "type": "integer", "description": "1 to include event log; 0 to skip." }, + "response_times": { "type": "integer", "description": "1 to include response time series." }, + "offset": { "type": "integer", "description": "Pagination offset (0-based)." }, + "limit": { "type": "integer", "description": "Page size (max 50)." } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/getMonitors", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { + "monitors": "$monitors", + "types": "$types", + "statuses": "$statuses", + "search": "$search", + "logs": "$logs", + "response_times": "$response_times", + "offset": "$offset", + "limit": "$limit" + } + } + }, + { + "name": "uptimerobot_new_monitor", + "description": "Create a new monitor. `type` is required (1=HTTP, 2=Keyword, 3=Ping, 4=Port, 5=Heartbeat). For type=2 you must also supply `keyword_type` (1=exists, 2=not exists) + `keyword_value`.", + "parameters": { + "type": "object", + "properties": { + "friendly_name": { "type": "string", "description": "Display name." }, + "url": { "type": "string", "description": "URL or IP to monitor." }, + "type": { "type": "integer", "description": "1=HTTP, 2=Keyword, 3=Ping, 4=Port, 5=Heartbeat." }, + "interval": { "type": "integer", "description": "Check interval in seconds (60-86400). Free plan minimum 300." }, + "timeout": { "type": "integer", "description": "Timeout in seconds." }, + "port": { "type": "integer", "description": "Port number (required if type=4)." }, + "keyword_type": { "type": "integer", "description": "1=exists, 2=not exists (required if type=2)." }, + "keyword_value": { "type": "string", "description": "Keyword to look for (required if type=2)." }, + "alert_contacts": { "type": "string", "description": "Format: '__-_..'." } + }, + "required": ["friendly_name", "url", "type"] + }, + "endpointMapping": { + "method": "POST", + "path": "/newMonitor", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { + "friendly_name": "$friendly_name", + "url": "$url", + "type": "$type", + "interval": "$interval", + "timeout": "$timeout", + "port": "$port", + "keyword_type": "$keyword_type", + "keyword_value": "$keyword_value", + "alert_contacts": "$alert_contacts" + } + } + }, + { + "name": "uptimerobot_edit_monitor", + "description": "Edit an existing monitor. Pass `id` + any field you want to update.", + "parameters": { + "type": "object", + "properties": { + "id": { "type": "integer", "description": "Monitor ID." }, + "friendly_name": { "type": "string", "description": "New display name." }, + "url": { "type": "string", "description": "New URL." }, + "interval": { "type": "integer", "description": "New check interval." }, + "status": { "type": "integer", "description": "0 to pause, 1 to resume." } + }, + "required": ["id"] + }, + "endpointMapping": { + "method": "POST", + "path": "/editMonitor", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { + "id": "$id", + "friendly_name": "$friendly_name", + "url": "$url", + "interval": "$interval", + "status": "$status" + } + } + }, + { + "name": "uptimerobot_delete_monitor", + "description": "Permanently delete a monitor. Irreversible.", + "parameters": { + "type": "object", + "properties": { + "id": { "type": "integer", "description": "Monitor ID." } + }, + "required": ["id"] + }, + "endpointMapping": { + "method": "POST", + "path": "/deleteMonitor", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { "id": "$id" } + } + }, + { + "name": "uptimerobot_get_alert_contacts", + "description": "List configured alert contacts (email, webhook, Slack, Telegram, SMS).", + "parameters": { + "type": "object", + "properties": { + "alert_contacts": { "type": "string", "description": "Comma-separated IDs (default: all)." }, + "offset": { "type": "integer", "description": "Pagination offset." }, + "limit": { "type": "integer", "description": "Page size." } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/getAlertContacts", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { + "alert_contacts": "$alert_contacts", + "offset": "$offset", + "limit": "$limit" + } + } + }, + { + "name": "uptimerobot_get_mwindows", + "description": "List maintenance windows.", + "parameters": { + "type": "object", + "properties": { + "mwindows": { "type": "string", "description": "Comma-separated maintenance window IDs (default: all)." } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/getMWindows", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { "mwindows": "$mwindows" } + } + }, + { + "name": "uptimerobot_get_psps", + "description": "List public status pages (PSPs) configured for this account.", + "parameters": { + "type": "object", + "properties": { + "psps": { "type": "string", "description": "Comma-separated PSP IDs (default: all)." } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/getPSPs", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { "psps": "$psps" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/uptime-robot.live.spec.ts b/packages/backend/src/adapters/intl/uptime-robot.live.spec.ts new file mode 100644 index 0000000..b2df437 --- /dev/null +++ b/packages/backend/src/adapters/intl/uptime-robot.live.spec.ts @@ -0,0 +1,17 @@ +import * as adapter from './uptime-robot.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: Record }; + tools: Array<{ endpointMapping: { method: string } }>; +}; +describe('uptime-robot adapter — static spec conformance', () => { + it('api.uptimerobot.com/v2 base URL', () => + expect(a.connector.baseUrl).toBe('https://api.uptimerobot.com/v2')); + it('QUERY_AUTH injects api_key + format=json on every request', () => { + expect(a.connector.authType).toBe('QUERY_AUTH'); + expect(Object.keys(a.connector.authConfig)).toContain('api_key'); + expect(a.connector.authConfig.format).toBe('json'); + }); + it('every tool POSTs', () => { + for (const t of a.tools) expect(t.endpointMapping.method).toBe('POST'); + }); +}); From 499df7412c14e2d9c25dbe36f9675a9684cfc8e0 Mon Sep 17 00:00:00 2001 From: Matteo Date: Wed, 20 May 2026 21:13:26 +0200 Subject: [PATCH 2/2] =?UTF-8?q?connectors:=20add=20batch=202b=20=E2=80=94?= =?UTF-8?q?=2033=20more=20greenfield=20SaaS=20adapters=20+=20engine=20fixe?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catalog grows 134 → 167 adapters. With this PR the original "+50 in 2 waves" plan lands: batch 2a (15) + batch 2b (33, deferring paystack duplicate to ng/) = 48 new adapters since main, on top of the 119 already shipped pre-batch-2. Categories covered in 2b: crm: zoho-crm, bitrix24, streak, nimble, nutshell-crm, agilecrm, less-annoying-crm, salesflare (8) accounting: freshbooks, wave-accounting, sage-business-cloud, invoiced, kashflow (5) hr: greenhouse, lever, workable, bamboohr, deel (5; personio already exists in de/) support: freshchat, tidio (2) project-management:wrike, teamwork-projects, plane-so (3) time-tracking: timetastic (1) email: mailerlite, omnisend (2) payments: plaid, gocardless, flutterwave (3; paystack already exists in ng/) social: pinterest, buffer (2) storage: dropbox, box (2) Engine + service fixes uncovered while authoring 2b: 1. rest.engine.ts OAUTH2 — added support for non-Bearer token prefixes via authConfig.tokenPrefix (defaults to "Bearer"). Required by Zoho, which sends "Authorization: Zoho-oauthtoken " instead. 2. adapters.service.ts importAdapter — was NOT persisting connector.headers from the catalog JSON, meaning adapters like harvest / freshservice / mastodon (shipped in 2a) silently dropped their per-tenant headers (Harvest-Account-Id, GoCardless-Version, etc.) at runtime. Fixed: headers are now resolved via {{VAR}} substitution from credentials and persisted on the Connector row. Also persists import credentials as connector.envVars so the runtime engine can resolve $VAR_NAME references inside bodyMapping / path / queryParams (used by plaid for the client_id+secret-in-body auth and less-annoying-crm for the Function-dispatch envelope). 3. catalog.spec.ts well-formed-endpointMapping — exempted env-var-style $UPPER_SNAKE_CASE references from the "must be a declared tool parameter" check, since those are now legitimately resolved at request time from connector.envVars. All 167 adapters validate via scripts/validate-adapters.mjs; 2436/2436 adapter tests pass. --- .../backend/src/adapters/adapters.service.ts | 17 ++ packages/backend/src/adapters/catalog.spec.ts | 8 +- packages/backend/src/adapters/catalog.ts | 66 ++++ .../backend/src/adapters/intl/agilecrm.json | 185 ++++++++++++ .../src/adapters/intl/agilecrm.live.spec.ts | 7 + .../backend/src/adapters/intl/bamboohr.json | 186 ++++++++++++ .../src/adapters/intl/bamboohr.live.spec.ts | 16 + .../backend/src/adapters/intl/bitrix24.json | 209 +++++++++++++ .../src/adapters/intl/bitrix24.live.spec.ts | 14 + packages/backend/src/adapters/intl/box.json | 223 ++++++++++++++ .../src/adapters/intl/box.live.spec.ts | 8 + .../backend/src/adapters/intl/buffer.json | 184 ++++++++++++ .../src/adapters/intl/buffer.live.spec.ts | 12 + packages/backend/src/adapters/intl/deel.json | 223 ++++++++++++++ .../src/adapters/intl/deel.live.spec.ts | 7 + .../backend/src/adapters/intl/dropbox.json | 209 +++++++++++++ .../src/adapters/intl/dropbox.live.spec.ts | 8 + .../src/adapters/intl/flutterwave.json | 236 +++++++++++++++ .../adapters/intl/flutterwave.live.spec.ts | 8 + .../backend/src/adapters/intl/freshbooks.json | 228 ++++++++++++++ .../src/adapters/intl/freshbooks.live.spec.ts | 8 + .../backend/src/adapters/intl/freshchat.json | 191 ++++++++++++ .../src/adapters/intl/freshchat.live.spec.ts | 7 + .../backend/src/adapters/intl/gocardless.json | 284 ++++++++++++++++++ .../src/adapters/intl/gocardless.live.spec.ts | 12 + .../backend/src/adapters/intl/greenhouse.json | 224 ++++++++++++++ .../src/adapters/intl/greenhouse.live.spec.ts | 8 + .../backend/src/adapters/intl/invoiced.json | 235 +++++++++++++++ .../src/adapters/intl/invoiced.live.spec.ts | 8 + .../backend/src/adapters/intl/kashflow.json | 152 ++++++++++ .../src/adapters/intl/kashflow.live.spec.ts | 15 + .../src/adapters/intl/less-annoying-crm.json | 231 ++++++++++++++ .../intl/less-annoying-crm.live.spec.ts | 15 + packages/backend/src/adapters/intl/lever.json | 213 +++++++++++++ .../src/adapters/intl/lever.live.spec.ts | 8 + .../backend/src/adapters/intl/mailerlite.json | 182 +++++++++++ .../src/adapters/intl/mailerlite.live.spec.ts | 7 + .../backend/src/adapters/intl/nimble.json | 195 ++++++++++++ .../src/adapters/intl/nimble.live.spec.ts | 12 + .../src/adapters/intl/nutshell-crm.json | 241 +++++++++++++++ .../adapters/intl/nutshell-crm.live.spec.ts | 8 + .../backend/src/adapters/intl/omnisend.json | 203 +++++++++++++ .../src/adapters/intl/omnisend.live.spec.ts | 12 + .../backend/src/adapters/intl/pinterest.json | 183 +++++++++++ .../src/adapters/intl/pinterest.live.spec.ts | 8 + packages/backend/src/adapters/intl/plaid.json | 246 +++++++++++++++ .../src/adapters/intl/plaid.live.spec.ts | 16 + .../backend/src/adapters/intl/plane-so.json | 220 ++++++++++++++ .../src/adapters/intl/plane-so.live.spec.ts | 12 + .../adapters/intl/sage-business-cloud.json | 182 +++++++++++ .../intl/sage-business-cloud.live.spec.ts | 12 + .../backend/src/adapters/intl/salesflare.json | 241 +++++++++++++++ .../src/adapters/intl/salesflare.live.spec.ts | 12 + .../backend/src/adapters/intl/streak.json | 157 ++++++++++ .../src/adapters/intl/streak.live.spec.ts | 12 + .../src/adapters/intl/teamwork-projects.json | 251 ++++++++++++++++ .../intl/teamwork-projects.live.spec.ts | 12 + packages/backend/src/adapters/intl/tidio.json | 151 ++++++++++ .../src/adapters/intl/tidio.live.spec.ts | 12 + .../backend/src/adapters/intl/timetastic.json | 162 ++++++++++ .../src/adapters/intl/timetastic.live.spec.ts | 7 + .../src/adapters/intl/wave-accounting.json | 185 ++++++++++++ .../intl/wave-accounting.live.spec.ts | 12 + .../backend/src/adapters/intl/workable.json | 165 ++++++++++ .../src/adapters/intl/workable.live.spec.ts | 7 + packages/backend/src/adapters/intl/wrike.json | 219 ++++++++++++++ .../src/adapters/intl/wrike.live.spec.ts | 7 + .../backend/src/adapters/intl/zoho-crm.json | 228 ++++++++++++++ .../src/adapters/intl/zoho-crm.live.spec.ts | 13 + .../src/connectors/engines/rest.engine.ts | 4 +- 70 files changed, 7258 insertions(+), 3 deletions(-) create mode 100644 packages/backend/src/adapters/intl/agilecrm.json create mode 100644 packages/backend/src/adapters/intl/agilecrm.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/bamboohr.json create mode 100644 packages/backend/src/adapters/intl/bamboohr.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/bitrix24.json create mode 100644 packages/backend/src/adapters/intl/bitrix24.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/box.json create mode 100644 packages/backend/src/adapters/intl/box.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/buffer.json create mode 100644 packages/backend/src/adapters/intl/buffer.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/deel.json create mode 100644 packages/backend/src/adapters/intl/deel.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/dropbox.json create mode 100644 packages/backend/src/adapters/intl/dropbox.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/flutterwave.json create mode 100644 packages/backend/src/adapters/intl/flutterwave.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/freshbooks.json create mode 100644 packages/backend/src/adapters/intl/freshbooks.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/freshchat.json create mode 100644 packages/backend/src/adapters/intl/freshchat.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/gocardless.json create mode 100644 packages/backend/src/adapters/intl/gocardless.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/greenhouse.json create mode 100644 packages/backend/src/adapters/intl/greenhouse.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/invoiced.json create mode 100644 packages/backend/src/adapters/intl/invoiced.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/kashflow.json create mode 100644 packages/backend/src/adapters/intl/kashflow.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/less-annoying-crm.json create mode 100644 packages/backend/src/adapters/intl/less-annoying-crm.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/lever.json create mode 100644 packages/backend/src/adapters/intl/lever.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/mailerlite.json create mode 100644 packages/backend/src/adapters/intl/mailerlite.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/nimble.json create mode 100644 packages/backend/src/adapters/intl/nimble.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/nutshell-crm.json create mode 100644 packages/backend/src/adapters/intl/nutshell-crm.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/omnisend.json create mode 100644 packages/backend/src/adapters/intl/omnisend.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/pinterest.json create mode 100644 packages/backend/src/adapters/intl/pinterest.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/plaid.json create mode 100644 packages/backend/src/adapters/intl/plaid.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/plane-so.json create mode 100644 packages/backend/src/adapters/intl/plane-so.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/sage-business-cloud.json create mode 100644 packages/backend/src/adapters/intl/sage-business-cloud.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/salesflare.json create mode 100644 packages/backend/src/adapters/intl/salesflare.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/streak.json create mode 100644 packages/backend/src/adapters/intl/streak.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/teamwork-projects.json create mode 100644 packages/backend/src/adapters/intl/teamwork-projects.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/tidio.json create mode 100644 packages/backend/src/adapters/intl/tidio.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/timetastic.json create mode 100644 packages/backend/src/adapters/intl/timetastic.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/wave-accounting.json create mode 100644 packages/backend/src/adapters/intl/wave-accounting.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/workable.json create mode 100644 packages/backend/src/adapters/intl/workable.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/wrike.json create mode 100644 packages/backend/src/adapters/intl/wrike.live.spec.ts create mode 100644 packages/backend/src/adapters/intl/zoho-crm.json create mode 100644 packages/backend/src/adapters/intl/zoho-crm.live.spec.ts diff --git a/packages/backend/src/adapters/adapters.service.ts b/packages/backend/src/adapters/adapters.service.ts index d080a09..2484f82 100644 --- a/packages/backend/src/adapters/adapters.service.ts +++ b/packages/backend/src/adapters/adapters.service.ts @@ -54,6 +54,21 @@ export class AdaptersService { // Resolve {{VAR}} placeholders in baseUrl (e.g. weclapp tenant) const resolvedBaseUrl = this.resolveString(adapter.connector.baseUrl, credentials); + // Resolve {{VAR}} placeholders in static connector headers (e.g. Harvest + // requires a per-tenant Harvest-Account-Id header on every call). + const adapterHeaders = (adapter.connector as { headers?: Record }).headers; + const resolvedHeaders = adapterHeaders + ? (this.resolveTemplate(adapterHeaders, credentials) as Record) + : null; + + // Persist the import credentials as envVars so the engine can use them + // for runtime $varname substitution inside tool bodies/queries/paths. + // (authConfig has its own {{VAR}} substitution; envVars covers everything + // outside auth/baseUrl.) + const envVarsToPersist = credentials && Object.keys(credentials).length > 0 + ? (credentials as Record) + : null; + const connector = await this.prisma.connector.create({ data: { userId, @@ -64,6 +79,8 @@ export class AdaptersService { isActive: true, authType: (adapter.connector.authType as any) || 'NONE', authConfig: encryptedAuth, + headers: resolvedHeaders as any, + envVars: envVarsToPersist as any, instructions: adapter.instructions || null, }, }); diff --git a/packages/backend/src/adapters/catalog.spec.ts b/packages/backend/src/adapters/catalog.spec.ts index fff3a46..95e01da 100644 --- a/packages/backend/src/adapters/catalog.spec.ts +++ b/packages/backend/src/adapters/catalog.spec.ts @@ -165,13 +165,17 @@ describe('adapter catalog', () => { const strings: Array<{ path: string; value: string }> = []; collectStrings(em[field], field, strings); for (const { value } of strings) { - // Full-string reference: "$foo" → must be declared, unless it's an env placeholder + // Full-string reference: "$foo" → must be declared as a tool param, + // unless it's $$ (escape) or an env-var-style reference (UPPER_SNAKE_CASE, + // resolved at runtime from connector.envVars populated at import time). const full = /^\$([\w$]+)$/.exec(value); - if (full && !value.startsWith('$$')) { + if (full && !value.startsWith('$$') && !/^[A-Z][A-Z0-9_]*$/.test(full[1])) { expect(declaredParams.has(full[1])).toBe(true); } // Embedded references: "...${foo}..." — all names must be declared + // (same env-var exemption applies). for (const match of value.matchAll(/\$\{([\w$]+)\}/g)) { + if (/^[A-Z][A-Z0-9_]*$/.test(match[1])) continue; expect(declaredParams.has(match[1])).toBe(true); } } diff --git a/packages/backend/src/adapters/catalog.ts b/packages/backend/src/adapters/catalog.ts index 97f0086..e1eb7de 100644 --- a/packages/backend/src/adapters/catalog.ts +++ b/packages/backend/src/adapters/catalog.ts @@ -34,14 +34,19 @@ 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 agilecrm from './intl/agilecrm.json'; import * as amadeus from './intl/amadeus.json'; import * as apollo from './intl/apollo.json'; import * as attio from './intl/attio.json'; +import * as bamboohr from './intl/bamboohr.json'; import * as basecamp from './intl/basecamp.json'; import * as beehiiv from './intl/beehiiv.json'; import * as bigcommerce from './intl/bigcommerce.json'; +import * as bitrix24 from './intl/bitrix24.json'; import * as bluesky from './intl/bluesky.json'; +import * as box from './intl/box.json'; import * as brevo from './intl/brevo.json'; +import * as buffer from './intl/buffer.json'; import * as bugsnag from './intl/bugsnag.json'; import * as calendly from './intl/calendly.json'; import * as chargebee from './intl/chargebee.json'; @@ -55,20 +60,27 @@ import * as convertkit from './intl/convertkit.json'; import * as copper from './intl/copper.json'; import * as crisp from './intl/crisp.json'; import * as datadog from './intl/datadog.json'; +import * as deel from './intl/deel.json'; import * as discordBot from './intl/discord-bot.json'; import * as drip from './intl/drip.json'; +import * as dropbox from './intl/dropbox.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'; +import * as flutterwave from './intl/flutterwave.json'; import * as folk from './intl/folk.json'; +import * as freshbooks from './intl/freshbooks.json'; +import * as freshchat from './intl/freshchat.json'; import * as freshdesk from './intl/freshdesk.json'; import * as freshservice from './intl/freshservice.json'; import * as front from './intl/front.json'; import * as ghost from './intl/ghost.json'; import * as gitbook from './intl/gitbook.json'; +import * as gocardless from './intl/gocardless.json'; import * as gorgias from './intl/gorgias.json'; +import * as greenhouse from './intl/greenhouse.json'; import * as hackernews from './intl/hackernews.json'; import * as harvest from './intl/harvest.json'; import * as heap from './intl/heap.json'; @@ -77,14 +89,19 @@ 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 invoiced from './intl/invoiced.json'; +import * as kashflow from './intl/kashflow.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 lessAnnoyingCrm from './intl/less-annoying-crm.json'; +import * as lever from './intl/lever.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 mailerlite from './intl/mailerlite.json'; import * as mailshake from './intl/mailshake.json'; import * as mapbox from './intl/mapbox.json'; import * as mastodon from './intl/mastodon.json'; @@ -97,13 +114,21 @@ import * as mollie from './intl/mollie.json'; import * as neverbounce from './intl/neverbounce.json'; import * as newRelic from './intl/new-relic.json'; import * as newsapi from './intl/newsapi.json'; +import * as nimble from './intl/nimble.json'; import * as nominatim from './intl/nominatim.json'; +import * as nutshellCrm from './intl/nutshell-crm.json'; +import * as omnisend from './intl/omnisend.json'; import * as openweather from './intl/openweather.json'; import * as outreach from './intl/outreach.json'; import * as pandadoc from './intl/pandadoc.json'; +import * as pinterest from './intl/pinterest.json'; import * as pipedrive from './intl/pipedrive.json'; +import * as plaid from './intl/plaid.json'; +import * as planeSo from './intl/plane-so.json'; import * as recurly from './intl/recurly.json'; import * as reddit from './intl/reddit.json'; +import * as sageBusinessCloud from './intl/sage-business-cloud.json'; +import * as salesflare from './intl/salesflare.json'; import * as salesloft from './intl/salesloft.json'; import * as savvycal from './intl/savvycal.json'; import * as sendgrid from './intl/sendgrid.json'; @@ -112,23 +137,31 @@ 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'; +import * as streak from './intl/streak.json'; import * as substack from './intl/substack.json'; import * as surveymonkey from './intl/surveymonkey.json'; import * as tally from './intl/tally.json'; +import * as teamworkProjects from './intl/teamwork-projects.json'; import * as telegramBot from './intl/telegram-bot.json'; import * as ticktick from './intl/ticktick.json'; +import * as tidio from './intl/tidio.json'; +import * as timetastic from './intl/timetastic.json'; import * as todoist from './intl/todoist.json'; import * as togglTrack from './intl/toggl-track.json'; import * as trello from './intl/trello.json'; import * as typeform from './intl/typeform.json'; import * as uptimeRobot from './intl/uptime-robot.json'; import * as vercelAnalytics from './intl/vercel-analytics.json'; +import * as waveAccounting from './intl/wave-accounting.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 workable from './intl/workable.json'; +import * as wrike from './intl/wrike.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 zohoCrm from './intl/zoho-crm.json'; import * as mercadoLibre from './br/mercado-libre.json'; import * as razorpay from './in/razorpay.json'; import * as lineMessaging from './jp/line-messaging.json'; @@ -240,14 +273,19 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ activecampaign as unknown as AdapterDefinition, acuityScheduling as unknown as AdapterDefinition, adyen as unknown as AdapterDefinition, + agilecrm as unknown as AdapterDefinition, amadeus as unknown as AdapterDefinition, apollo as unknown as AdapterDefinition, attio as unknown as AdapterDefinition, + bamboohr as unknown as AdapterDefinition, basecamp as unknown as AdapterDefinition, beehiiv as unknown as AdapterDefinition, bigcommerce as unknown as AdapterDefinition, + bitrix24 as unknown as AdapterDefinition, bluesky as unknown as AdapterDefinition, + box as unknown as AdapterDefinition, brevo as unknown as AdapterDefinition, + buffer as unknown as AdapterDefinition, bugsnag as unknown as AdapterDefinition, calendly as unknown as AdapterDefinition, chargebee as unknown as AdapterDefinition, @@ -261,20 +299,27 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ copper as unknown as AdapterDefinition, crisp as unknown as AdapterDefinition, datadog as unknown as AdapterDefinition, + deel as unknown as AdapterDefinition, discordBot as unknown as AdapterDefinition, drip as unknown as AdapterDefinition, + dropbox 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, + flutterwave as unknown as AdapterDefinition, folk as unknown as AdapterDefinition, + freshbooks as unknown as AdapterDefinition, + freshchat as unknown as AdapterDefinition, freshdesk as unknown as AdapterDefinition, freshservice as unknown as AdapterDefinition, front as unknown as AdapterDefinition, ghost as unknown as AdapterDefinition, gitbook as unknown as AdapterDefinition, + gocardless as unknown as AdapterDefinition, gorgias as unknown as AdapterDefinition, + greenhouse as unknown as AdapterDefinition, hackernews as unknown as AdapterDefinition, harvest as unknown as AdapterDefinition, heap as unknown as AdapterDefinition, @@ -283,14 +328,19 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ hunter as unknown as AdapterDefinition, insightly as unknown as AdapterDefinition, instantly as unknown as AdapterDefinition, + invoiced as unknown as AdapterDefinition, + kashflow as unknown as AdapterDefinition, klaviyo as unknown as AdapterDefinition, kustomer as unknown as AdapterDefinition, lemlist as unknown as AdapterDefinition, lemonsqueezy as unknown as AdapterDefinition, + lessAnnoyingCrm as unknown as AdapterDefinition, + lever as unknown as AdapterDefinition, linkedin as unknown as AdapterDefinition, loops as unknown as AdapterDefinition, magento as unknown as AdapterDefinition, mailchimp as unknown as AdapterDefinition, + mailerlite as unknown as AdapterDefinition, mailshake as unknown as AdapterDefinition, mapbox as unknown as AdapterDefinition, mastodon as unknown as AdapterDefinition, @@ -303,13 +353,21 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ neverbounce as unknown as AdapterDefinition, newRelic as unknown as AdapterDefinition, newsapi as unknown as AdapterDefinition, + nimble as unknown as AdapterDefinition, nominatim as unknown as AdapterDefinition, + nutshellCrm as unknown as AdapterDefinition, + omnisend as unknown as AdapterDefinition, openweather as unknown as AdapterDefinition, outreach as unknown as AdapterDefinition, pandadoc as unknown as AdapterDefinition, + pinterest as unknown as AdapterDefinition, pipedrive as unknown as AdapterDefinition, + plaid as unknown as AdapterDefinition, + planeSo as unknown as AdapterDefinition, recurly as unknown as AdapterDefinition, reddit as unknown as AdapterDefinition, + sageBusinessCloud as unknown as AdapterDefinition, + salesflare as unknown as AdapterDefinition, salesloft as unknown as AdapterDefinition, savvycal as unknown as AdapterDefinition, sendgrid as unknown as AdapterDefinition, @@ -318,23 +376,31 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ snov as unknown as AdapterDefinition, sorare as unknown as AdapterDefinition, statsig as unknown as AdapterDefinition, + streak as unknown as AdapterDefinition, substack as unknown as AdapterDefinition, surveymonkey as unknown as AdapterDefinition, tally as unknown as AdapterDefinition, + teamworkProjects as unknown as AdapterDefinition, telegramBot as unknown as AdapterDefinition, ticktick as unknown as AdapterDefinition, + tidio as unknown as AdapterDefinition, + timetastic as unknown as AdapterDefinition, todoist as unknown as AdapterDefinition, togglTrack as unknown as AdapterDefinition, trello as unknown as AdapterDefinition, typeform as unknown as AdapterDefinition, uptimeRobot as unknown as AdapterDefinition, vercelAnalytics as unknown as AdapterDefinition, + waveAccounting as unknown as AdapterDefinition, whatsappBusiness as unknown as AdapterDefinition, woocommerce as unknown as AdapterDefinition, wordpress as unknown as AdapterDefinition, + workable as unknown as AdapterDefinition, + wrike as unknown as AdapterDefinition, wufoo as unknown as AdapterDefinition, youtubeData as unknown as AdapterDefinition, zendesk as unknown as AdapterDefinition, + zohoCrm 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/agilecrm.json b/packages/backend/src/adapters/intl/agilecrm.json new file mode 100644 index 0000000..a4f8579 --- /dev/null +++ b/packages/backend/src/adapters/intl/agilecrm.json @@ -0,0 +1,185 @@ +{ + "slug": "agilecrm", + "name": "Agile CRM", + "description": "Manage Agile CRM (contacts, deals, tasks, notes, campaigns) from any AI agent. 9 tools, basic-auth with email + API key.", + "instructions": "This connector wraps the Agile CRM REST API (per-domain — yourdomain.agilecrm.com/dev/api).\n\n**Setup**:\n1. Sign in to your Agile CRM instance → top-right avatar → **Admin Settings → Developers & API → API key**. Copy.\n2. Note your subdomain (the part before `.agilecrm.com`).\n3. Set `AGILECRM_DOMAIN` (just subdomain), `AGILECRM_EMAIL` (your login email), `AGILECRM_API_KEY`.\n\n**Authentication**: HTTP Basic with `email:apikey` pair. The API also accepts a `key` query param; we use Basic for consistency.\n\n**Per-tenant baseUrl**: `https://${AGILECRM_DOMAIN}.agilecrm.com/dev/api` — substituted at import time.\n\n**Contact properties model**: contacts have a `properties[]` array of {name, value, type, subtype} pairs (NOT a flat field map). Standard names: `first_name`, `last_name`, `email`, `phone`, `company`, `title`. Custom fields appear in the same array.\n\n**Pagination**: cursor-based — pass `cursor` from previous response. Page size via `page_size` (max 100).\n\n**Rate limits**: 50 req/sec per account. 429 → back off 1s.\n\n**Out of scope here**: campaign workflow execution, web-form submission, ticket helpdesk, telephony.", + "region": "intl", + "category": "crm", + "icon": "agilecrm", + "docsUrl": "https://github.com/agilecrm/rest-api", + "requiredEnvVars": ["AGILECRM_DOMAIN", "AGILECRM_EMAIL", "AGILECRM_API_KEY"], + "connector": { + "name": "Agile CRM REST API", + "type": "REST", + "baseUrl": "https://{{AGILECRM_DOMAIN}}.agilecrm.com/dev/api", + "authType": "BASIC_AUTH", + "authConfig": { + "username": "{{AGILECRM_EMAIL}}", + "password": "{{AGILECRM_API_KEY}}" + } + }, + "tools": [ + { + "name": "agilecrm_list_contacts", + "description": "List contacts. Cursor-paginated.", + "parameters": { + "type": "object", + "properties": { + "page_size": { "type": "integer", "description": "Max 100." }, + "cursor": { "type": "string", "description": "Pagination cursor from previous response." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/contacts", + "queryParams": { "page_size": "$page_size", "global_sort_key": "-created_time", "cursor": "$cursor" } + } + }, + { + "name": "agilecrm_get_contact", + "description": "Get one contact by ID.", + "parameters": { + "type": "object", + "properties": { + "id": { "type": "string", "description": "Contact ID." } + }, + "required": ["id"] + }, + "endpointMapping": { "method": "GET", "path": "/contacts/{id}" } + }, + { + "name": "agilecrm_get_contact_by_email", + "description": "Look up a contact by email — much faster than scanning the list.", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "Email address." } + }, + "required": ["email"] + }, + "endpointMapping": { "method": "GET", "path": "/contacts/search/email/{email}" } + }, + { + "name": "agilecrm_create_contact", + "description": "Create a contact. Pass `properties` as the Agile CRM array shape.", + "parameters": { + "type": "object", + "properties": { + "properties": { "type": "array", "description": "Array of {name, value, type:'SYSTEM'|'CUSTOM', subtype?} entries." }, + "tags": { "type": "array", "description": "Array of tag strings." }, + "lead_score": { "type": "integer", "description": "Initial lead score." } + }, + "required": ["properties"] + }, + "endpointMapping": { + "method": "POST", + "path": "/contacts", + "bodyMapping": { + "properties": "$properties", + "tags": "$tags", + "lead_score": "$lead_score" + } + } + }, + { + "name": "agilecrm_update_contact", + "description": "Update a contact — supply `id` and the `properties` to merge.", + "parameters": { + "type": "object", + "properties": { + "id": { "type": "string", "description": "Contact ID." }, + "properties": { "type": "array", "description": "Properties array to merge." } + }, + "required": ["id", "properties"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/contacts/edit-properties", + "bodyMapping": { "id": "$id", "properties": "$properties" } + } + }, + { + "name": "agilecrm_delete_contact", + "description": "Delete a contact by ID.", + "parameters": { + "type": "object", + "properties": { + "id": { "type": "string", "description": "Contact ID." } + }, + "required": ["id"] + }, + "endpointMapping": { "method": "DELETE", "path": "/contacts/{id}" } + }, + { + "name": "agilecrm_list_deals", + "description": "List deals/opportunities.", + "parameters": { + "type": "object", + "properties": { + "page_size": { "type": "integer", "description": "Max 100." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/opportunity", + "queryParams": { "page_size": "$page_size" } + } + }, + { + "name": "agilecrm_create_deal", + "description": "Create a deal.", + "parameters": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Deal name." }, + "expected_value": { "type": "number", "description": "Expected revenue." }, + "probability": { "type": "integer", "description": "0-100 close probability." }, + "milestone": { "type": "string", "description": "Pipeline stage name." }, + "close_date": { "type": "integer", "description": "Expected close UNIX epoch (seconds)." }, + "contact_ids": { "type": "array", "description": "Array of contact ID strings to link." } + }, + "required": ["name", "expected_value", "milestone"] + }, + "endpointMapping": { + "method": "POST", + "path": "/opportunity", + "bodyMapping": { + "name": "$name", + "expected_value": "$expected_value", + "probability": "$probability", + "milestone": "$milestone", + "close_date": "$close_date", + "contact_ids": "$contact_ids" + } + } + }, + { + "name": "agilecrm_create_task", + "description": "Create a task linked to one or more contacts.", + "parameters": { + "type": "object", + "properties": { + "subject": { "type": "string", "description": "Task subject." }, + "type": { "type": "string", "description": "CALL, EMAIL, FOLLOW_UP, MEETING, MILESTONE, SEND, TWEET, OTHER." }, + "priority_type": { "type": "string", "description": "HIGH, NORMAL, LOW." }, + "status": { "type": "string", "description": "YET_TO_START, IN_PROGRESS, COMPLETED." }, + "due": { "type": "integer", "description": "Due UNIX epoch (seconds)." }, + "contacts": { "type": "array", "description": "Array of contact ID strings." } + }, + "required": ["subject", "type", "due"] + }, + "endpointMapping": { + "method": "POST", + "path": "/tasks", + "bodyMapping": { + "subject": "$subject", + "task_type": "$type", + "priority_type": "$priority_type", + "status": "$status", + "due": "$due", + "contacts": "$contacts" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/agilecrm.live.spec.ts b/packages/backend/src/adapters/intl/agilecrm.live.spec.ts new file mode 100644 index 0000000..b6bb131 --- /dev/null +++ b/packages/backend/src/adapters/intl/agilecrm.live.spec.ts @@ -0,0 +1,7 @@ +import * as adapter from './agilecrm.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string } }; +describe('agilecrm adapter — static spec conformance', () => { + it('per-tenant baseUrl with subdomain placeholder', () => + expect(a.connector.baseUrl).toBe('https://{{AGILECRM_DOMAIN}}.agilecrm.com/dev/api')); + it('Basic auth', () => expect(a.connector.authType).toBe('BASIC_AUTH')); +}); diff --git a/packages/backend/src/adapters/intl/bamboohr.json b/packages/backend/src/adapters/intl/bamboohr.json new file mode 100644 index 0000000..529acec --- /dev/null +++ b/packages/backend/src/adapters/intl/bamboohr.json @@ -0,0 +1,186 @@ +{ + "slug": "bamboohr", + "name": "BambooHR", + "description": "Manage BambooHR (employees, time-off, custom reports, fields, files) from any AI agent. 9 tools, basic-auth.", + "instructions": "This connector wraps the BambooHR REST API v1 (per-subdomain — api.bamboohr.com/api/gateway.php//v1).\n\n**Setup**:\n1. Sign in to your BambooHR portal → top-right avatar → **API Keys → Add a new key**.\n2. Note your subdomain (the part before `.bamboohr.com`) and copy the key.\n3. Set `BAMBOOHR_SUBDOMAIN` and `BAMBOOHR_API_KEY`.\n\n**Authentication**: HTTP Basic — API key as username, literal `x` as password.\n\n**Path subdomain**: BambooHR's API embeds the subdomain in the URL path. The adapter substitutes via runtime envVar.\n\n**Custom fields**: pass `fields` as comma-separated field IDs (e.g. `firstName,lastName,workEmail,jobTitle,supervisor,customField4000010`). Without `fields`, only the basic employee profile is returned.\n\n**JSON response**: set `Accept: application/json` (the adapter does this) — otherwise BambooHR returns XML.\n\n**Rate limits**: not publicly documented; expect 60 req/min as a soft cap. 429 with `Retry-After`.\n\n**Out of scope here**: payroll, performance review CRUD, signed-document workflows, the Time Tracking add-on API.", + "region": "intl", + "category": "hr", + "icon": "bamboohr", + "docsUrl": "https://documentation.bamboohr.com/reference/", + "requiredEnvVars": ["BAMBOOHR_SUBDOMAIN", "BAMBOOHR_API_KEY"], + "connector": { + "name": "BambooHR API v1", + "type": "REST", + "baseUrl": "https://api.bamboohr.com/api/gateway.php", + "authType": "BASIC_AUTH", + "authConfig": { + "username": "{{BAMBOOHR_API_KEY}}", + "password": "x" + } + }, + "tools": [ + { + "name": "bamboohr_list_employees_directory", + "description": "Returns the company directory: every employee's id, displayName, jobTitle, workEmail, department, division, location, supervisor.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { + "method": "GET", + "path": "/{BAMBOOHR_SUBDOMAIN}/v1/employees/directory", + "headers": { "Accept": "application/json" } + } + }, + { + "name": "bamboohr_get_employee", + "description": "Get one employee by ID. Pass `fields` (comma-separated) to control what's returned — by default only id+name.", + "parameters": { + "type": "object", + "properties": { + "employee_id": { "type": "string", "description": "Employee ID." }, + "fields": { "type": "string", "description": "Comma-separated field IDs." } + }, + "required": ["employee_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/{BAMBOOHR_SUBDOMAIN}/v1/employees/{employee_id}", + "queryParams": { "fields": "$fields" }, + "headers": { "Accept": "application/json" } + } + }, + { + "name": "bamboohr_add_employee", + "description": "Create a new employee. Required: firstName + lastName. Returns the new employee URL in the Location header.", + "parameters": { + "type": "object", + "properties": { + "firstName": { "type": "string", "description": "First name." }, + "lastName": { "type": "string", "description": "Last name." }, + "jobTitle": { "type": "string", "description": "Job title." }, + "department": { "type": "string", "description": "Department." }, + "workEmail": { "type": "string", "description": "Work email." }, + "hireDate": { "type": "string", "description": "YYYY-MM-DD." }, + "supervisorEmail": { "type": "string", "description": "Supervisor's work email." }, + "employeeNumber": { "type": "string", "description": "Internal employee number." } + }, + "required": ["firstName", "lastName"] + }, + "endpointMapping": { + "method": "POST", + "path": "/{BAMBOOHR_SUBDOMAIN}/v1/employees/", + "bodyMapping": { + "firstName": "$firstName", + "lastName": "$lastName", + "jobTitle": "$jobTitle", + "department": "$department", + "workEmail": "$workEmail", + "hireDate": "$hireDate", + "supervisorEmail": "$supervisorEmail", + "employeeNumber": "$employeeNumber" + }, + "headers": { "Accept": "application/json" } + } + }, + { + "name": "bamboohr_update_employee", + "description": "Update employee fields.", + "parameters": { + "type": "object", + "properties": { + "employee_id": { "type": "string", "description": "Employee ID." }, + "fields": { "type": "object", "description": "Field-id → value map (e.g. {jobTitle:'Sr Engineer', workEmail:'new@x.com'})." } + }, + "required": ["employee_id", "fields"] + }, + "endpointMapping": { + "method": "POST", + "path": "/{BAMBOOHR_SUBDOMAIN}/v1/employees/{employee_id}", + "bodyMapping": { "fields": "$fields" }, + "headers": { "Accept": "application/json" } + } + }, + { + "name": "bamboohr_who_is_out", + "description": "Returns the company-wide who's-out list for a date range.", + "parameters": { + "type": "object", + "properties": { + "start": { "type": "string", "description": "YYYY-MM-DD." }, + "end": { "type": "string", "description": "YYYY-MM-DD." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/{BAMBOOHR_SUBDOMAIN}/v1/time_off/whos_out", + "queryParams": { "start": "$start", "end": "$end" }, + "headers": { "Accept": "application/json" } + } + }, + { + "name": "bamboohr_list_time_off_requests", + "description": "List time-off requests. Filter by employee, date range, status.", + "parameters": { + "type": "object", + "properties": { + "employeeId": { "type": "string", "description": "Filter by employee." }, + "start": { "type": "string", "description": "YYYY-MM-DD." }, + "end": { "type": "string", "description": "YYYY-MM-DD." }, + "status": { "type": "string", "description": "approved, denied, superceded, requested, canceled." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/{BAMBOOHR_SUBDOMAIN}/v1/time_off/requests/", + "queryParams": { + "employeeId": "$employeeId", + "start": "$start", + "end": "$end", + "status": "$status" + }, + "headers": { "Accept": "application/json" } + } + }, + { + "name": "bamboohr_list_meta_fields", + "description": "List every field defined in this account (built-in + custom). Use to discover field IDs.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { + "method": "GET", + "path": "/{BAMBOOHR_SUBDOMAIN}/v1/meta/fields", + "headers": { "Accept": "application/json" } + } + }, + { + "name": "bamboohr_run_custom_report", + "description": "Run a pre-defined custom report by its numeric ID. Returns the rows as JSON.", + "parameters": { + "type": "object", + "properties": { + "report_id": { "type": "integer", "description": "Report ID." } + }, + "required": ["report_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/{BAMBOOHR_SUBDOMAIN}/v1/reports/{report_id}", + "queryParams": { "format": "JSON", "fd": "yes" }, + "headers": { "Accept": "application/json" } + } + }, + { + "name": "bamboohr_get_employee_files", + "description": "List files in an employee's record categorized by section.", + "parameters": { + "type": "object", + "properties": { + "employee_id": { "type": "string", "description": "Employee ID." } + }, + "required": ["employee_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/{BAMBOOHR_SUBDOMAIN}/v1/employees/{employee_id}/files/view/", + "headers": { "Accept": "application/json" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/bamboohr.live.spec.ts b/packages/backend/src/adapters/intl/bamboohr.live.spec.ts new file mode 100644 index 0000000..3ad5e52 --- /dev/null +++ b/packages/backend/src/adapters/intl/bamboohr.live.spec.ts @@ -0,0 +1,16 @@ +import * as adapter from './bamboohr.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: { password: string } }; + tools: Array<{ endpointMapping: { path: string } }>; +}; +describe('bamboohr adapter — static spec conformance', () => { + it('api.bamboohr.com/api/gateway.php base URL', () => + expect(a.connector.baseUrl).toBe('https://api.bamboohr.com/api/gateway.php')); + it('Basic auth with literal password "x"', () => { + expect(a.connector.authType).toBe('BASIC_AUTH'); + expect(a.connector.authConfig.password).toBe('x'); + }); + it('every tool path embeds the subdomain placeholder', () => { + for (const t of a.tools) expect(t.endpointMapping.path).toContain('{BAMBOOHR_SUBDOMAIN}'); + }); +}); diff --git a/packages/backend/src/adapters/intl/bitrix24.json b/packages/backend/src/adapters/intl/bitrix24.json new file mode 100644 index 0000000..86a0b8c --- /dev/null +++ b/packages/backend/src/adapters/intl/bitrix24.json @@ -0,0 +1,209 @@ +{ + "slug": "bitrix24", + "name": "Bitrix24", + "description": "Manage Bitrix24 (CRM + tasks + workgroups + chat) via inbound webhooks from any AI agent. 10 tools, webhook-URL auth.", + "instructions": "This connector wraps the Bitrix24 REST API exposed via inbound webhooks.\n\n**Setup**:\n1. Log into your Bitrix24 portal (e.g. `myteam.bitrix24.com`) → **Developer resources → Other → Inbound webhook**.\n2. Pick scopes: at minimum `crm` (deals/contacts/companies/leads), `task` (tasks), `user` (users). Add `chat`, `socialnetwork` if needed.\n3. Save → copy the generated webhook URL of the form `https://YOURACCOUNT.bitrix24.com/rest/USER_ID/WEBHOOK_CODE/`. This is the secret — anyone with this URL can call the API as that user.\n4. Set `BITRIX24_WEBHOOK_URL` to the full URL (no trailing slash — the adapter adds it).\n\n**Authentication**: NO header / no token — auth is embedded in the URL path itself (`/rest/USER_ID/WEBHOOK_CODE/method.name`). Each method name (`crm.deal.list`, `tasks.task.add`, ...) becomes the last path segment.\n\n**Method naming**: Bitrix24 methods are dot-separated noun.entity.action — `crm.deal.list`, `crm.contact.add`, `tasks.task.update`. The adapter exposes a generic `bitrix24_call` wildcard plus typed wrappers for the most common CRM methods.\n\n**Parameters**: Bitrix uses POST with form-encoded body (`fields[TITLE]=...&fields[STAGE_ID]=...`). The adapter wraps this — pass `fields` as an object and the engine form-encodes it.\n\n**Pagination**: `start` (0-based offset). Response has `next` (next offset) and `total`.\n\n**Rate limits**: 2 req/sec per webhook URL; 10k methods/day on free plans, higher on paid. 429 returns `error: QUERY_LIMIT_EXCEEDED` — back off 1s.\n\n**On-premise**: same API works on self-hosted Bitrix24 — just point `BITRIX24_WEBHOOK_URL` at your install.\n\n**Out of scope here**: outbound webhook subscription, bizproc workflow execution, Bitrix24 cloud telephony (separate scope).", + "region": "intl", + "category": "crm", + "icon": "bitrix24", + "docsUrl": "https://training.bitrix24.com/rest_help/", + "requiredEnvVars": ["BITRIX24_WEBHOOK_URL"], + "connector": { + "name": "Bitrix24 Inbound Webhook", + "type": "REST", + "baseUrl": "{{BITRIX24_WEBHOOK_URL}}", + "authType": "NONE", + "authConfig": {} + }, + "tools": [ + { + "name": "bitrix24_call", + "description": "Generic Bitrix24 method invoker — pass any `method` name and `params` object. Use this for any method not covered by the typed wrappers below.", + "parameters": { + "type": "object", + "properties": { + "method": { "type": "string", "description": "Bitrix method name, e.g. 'crm.deal.list', 'tasks.task.get'." }, + "params": { "type": "object", "description": "Free-form parameters object — engine form-encodes nested keys as Bitrix expects (e.g. {select:[...], filter:{...}})." } + }, + "required": ["method"] + }, + "endpointMapping": { + "method": "POST", + "path": "/{method}", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { "params": "$params" } + } + }, + { + "name": "bitrix24_list_deals", + "description": "List CRM deals (opportunities). Returns each deal's ID, TITLE, STAGE_ID, OPPORTUNITY, CURRENCY_ID, ASSIGNED_BY_ID, COMPANY_ID, CONTACT_ID, DATE_CREATE.", + "parameters": { + "type": "object", + "properties": { + "select": { "type": "array", "description": "Array of field names to return. '*' for all." }, + "filter": { "type": "object", "description": "Field-value filter map." }, + "order": { "type": "object", "description": "Field → 'ASC'|'DESC' sort map." }, + "start": { "type": "integer", "description": "0-based pagination offset (step 50)." } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/crm.deal.list", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { + "select": "$select", + "filter": "$filter", + "order": "$order", + "start": "$start" + } + } + }, + { + "name": "bitrix24_get_deal", + "description": "Get one deal by ID with all standard + custom fields.", + "parameters": { + "type": "object", + "properties": { + "id": { "type": "integer", "description": "Deal ID." } + }, + "required": ["id"] + }, + "endpointMapping": { + "method": "POST", + "path": "/crm.deal.get", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { "id": "$id" } + } + }, + { + "name": "bitrix24_add_deal", + "description": "Create a new deal. `fields` is an object of Bitrix deal field names → values (TITLE, STAGE_ID, OPPORTUNITY, CURRENCY_ID, CONTACT_ID, COMPANY_ID, ASSIGNED_BY_ID, CATEGORY_ID, COMMENTS, ...).", + "parameters": { + "type": "object", + "properties": { + "fields": { "type": "object", "description": "Deal field map." }, + "params": { "type": "object", "description": "Behaviour flags, e.g. {REGISTER_SONET_EVENT:'Y'}." } + }, + "required": ["fields"] + }, + "endpointMapping": { + "method": "POST", + "path": "/crm.deal.add", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { "fields": "$fields", "params": "$params" } + } + }, + { + "name": "bitrix24_update_deal", + "description": "Update a deal's fields.", + "parameters": { + "type": "object", + "properties": { + "id": { "type": "integer", "description": "Deal ID." }, + "fields": { "type": "object", "description": "Field map to update." } + }, + "required": ["id", "fields"] + }, + "endpointMapping": { + "method": "POST", + "path": "/crm.deal.update", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { "id": "$id", "fields": "$fields" } + } + }, + { + "name": "bitrix24_list_contacts", + "description": "List CRM contacts.", + "parameters": { + "type": "object", + "properties": { + "select": { "type": "array", "description": "Field names." }, + "filter": { "type": "object", "description": "Filter map." }, + "order": { "type": "object", "description": "Sort map." }, + "start": { "type": "integer", "description": "Offset (step 50)." } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/crm.contact.list", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { + "select": "$select", + "filter": "$filter", + "order": "$order", + "start": "$start" + } + } + }, + { + "name": "bitrix24_add_contact", + "description": "Create a contact. Standard fields: NAME, LAST_NAME, SECOND_NAME, EMAIL (array of {VALUE,VALUE_TYPE}), PHONE (same shape), COMPANY_ID, ASSIGNED_BY_ID.", + "parameters": { + "type": "object", + "properties": { + "fields": { "type": "object", "description": "Contact field map." } + }, + "required": ["fields"] + }, + "endpointMapping": { + "method": "POST", + "path": "/crm.contact.add", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { "fields": "$fields" } + } + }, + { + "name": "bitrix24_list_companies", + "description": "List CRM companies.", + "parameters": { + "type": "object", + "properties": { + "select": { "type": "array", "description": "Field names." }, + "filter": { "type": "object", "description": "Filter map." }, + "start": { "type": "integer", "description": "Offset." } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/crm.company.list", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { "select": "$select", "filter": "$filter", "start": "$start" } + } + }, + { + "name": "bitrix24_add_task", + "description": "Create a task. Required fields: TITLE, RESPONSIBLE_ID. Other useful: DESCRIPTION, DEADLINE (ISO 8601), PRIORITY (0=normal, 2=high), GROUP_ID (project), CREATED_BY.", + "parameters": { + "type": "object", + "properties": { + "fields": { "type": "object", "description": "Task field map." } + }, + "required": ["fields"] + }, + "endpointMapping": { + "method": "POST", + "path": "/tasks.task.add", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { "fields": "$fields" } + } + }, + { + "name": "bitrix24_list_users", + "description": "List users in the portal. Returns each user's ID, NAME, LAST_NAME, EMAIL, WORK_POSITION, ACTIVE.", + "parameters": { + "type": "object", + "properties": { + "FILTER": { "type": "object", "description": "Filter map." }, + "SORT": { "type": "string", "description": "Field name to sort by." }, + "ORDER": { "type": "string", "description": "ASC or DESC." } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/user.get", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { "FILTER": "$FILTER", "SORT": "$SORT", "ORDER": "$ORDER" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/bitrix24.live.spec.ts b/packages/backend/src/adapters/intl/bitrix24.live.spec.ts new file mode 100644 index 0000000..3eb72d9 --- /dev/null +++ b/packages/backend/src/adapters/intl/bitrix24.live.spec.ts @@ -0,0 +1,14 @@ +import * as adapter from './bitrix24.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string }; + tools: Array<{ endpointMapping: { bodyEncoding?: string } }>; +}; +describe('bitrix24 adapter — static spec conformance', () => { + it('per-tenant webhook URL placeholder', () => + expect(a.connector.baseUrl).toBe('{{BITRIX24_WEBHOOK_URL}}')); + it('no header auth (URL contains the secret webhook code)', () => + expect(a.connector.authType).toBe('NONE')); + it('every typed tool uses form-urlencoded body', () => { + for (const t of a.tools) expect(t.endpointMapping.bodyEncoding).toBe('form-urlencoded'); + }); +}); diff --git a/packages/backend/src/adapters/intl/box.json b/packages/backend/src/adapters/intl/box.json new file mode 100644 index 0000000..00134b5 --- /dev/null +++ b/packages/backend/src/adapters/intl/box.json @@ -0,0 +1,223 @@ +{ + "slug": "box", + "name": "Box", + "description": "Manage Box (enterprise content: files, folders, collaborations, comments, tasks, metadata) from any AI agent. 10 tools, OAuth2 token.", + "instructions": "This connector wraps the Box Content API v2 (api.box.com/2.0).\n\n**Setup — OAuth2**:\n1. Register at https://app.box.com/developers/console → **Create New App** → 'OAuth 2.0 with JWT' or 'OAuth 2.0 with User Authentication'.\n2. For user OAuth: complete the auth flow at `https://account.box.com/api/oauth2/authorize?response_type=code&client_id=...&redirect_uri=...`.\n3. Exchange the code at `https://api.box.com/oauth2/token`. Save the refresh_token.\n4. For JWT (server auth): generate an enterprise-level access token differently — out of scope for this OAuth-based adapter.\n5. Set `BOX_CLIENT_ID`, `BOX_CLIENT_SECRET`, `BOX_REFRESH_TOKEN`.\n\n**Authentication**: OAuth2 refresh handled by engine. Sends `Authorization: Bearer ACCESS_TOKEN`.\n\n**Folder ID 0** is the root folder of the authenticated user.\n\n**File-vs-Folder IDs**: Box assigns numeric IDs that are unique within file-space and folder-space but can collide across (file 123 ≠ folder 123). Always know whether you're operating on a file or folder.\n\n**Upload**: file upload uses a different host (`upload.box.com`) with multipart. Out of scope here for content upload — use the create_shared_link pattern with externally-hosted files for ingest.\n\n**Pagination**: `limit` (max 1000) + `offset` (0-based). Some endpoints support `marker` cursor pagination — pass `usemarker=true`.\n\n**Rate limits**: ~16 req/sec per token, soft. 429 with `Retry-After`.\n\n**Out of scope here**: file upload multipart, Box Sign workflows, retention policies, the new Box AI inference endpoints.", + "region": "intl", + "category": "storage", + "icon": "box", + "docsUrl": "https://developer.box.com/reference/", + "requiredEnvVars": ["BOX_CLIENT_ID", "BOX_CLIENT_SECRET", "BOX_REFRESH_TOKEN"], + "connector": { + "name": "Box Content API v2", + "type": "REST", + "baseUrl": "https://api.box.com/2.0", + "authType": "OAUTH2", + "authConfig": { + "clientId": "{{BOX_CLIENT_ID}}", + "clientSecret": "{{BOX_CLIENT_SECRET}}", + "refreshToken": "{{BOX_REFRESH_TOKEN}}", + "tokenUrl": "https://api.box.com/oauth2/token" + } + }, + "tools": [ + { + "name": "box_get_current_user", + "description": "Return the authenticated user.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/users/me" } + }, + { + "name": "box_get_folder_items", + "description": "List items in a folder (files + subfolders). Use folder_id=0 for the root.", + "parameters": { + "type": "object", + "properties": { + "folder_id": { "type": "string", "description": "Folder ID (use '0' for root)." }, + "fields": { "type": "string", "description": "Comma-separated fields to project, e.g. 'name,size,modified_at,modified_by'." }, + "limit": { "type": "integer", "description": "Max 1000." }, + "offset": { "type": "integer", "description": "0-based offset." } + }, + "required": ["folder_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/folders/{folder_id}/items", + "queryParams": { + "fields": "$fields", + "limit": "$limit", + "offset": "$offset" + } + } + }, + { + "name": "box_get_folder", + "description": "Get folder metadata (name, created_by, modified_by, item_collection summary).", + "parameters": { + "type": "object", + "properties": { + "folder_id": { "type": "string", "description": "Folder ID." }, + "fields": { "type": "string", "description": "Fields to project." } + }, + "required": ["folder_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/folders/{folder_id}", + "queryParams": { "fields": "$fields" } + } + }, + { + "name": "box_create_folder", + "description": "Create a folder inside a parent.", + "parameters": { + "type": "object", + "properties": { + "parent_id": { "type": "string", "description": "Parent folder ID." }, + "name": { "type": "string", "description": "Folder name." } + }, + "required": ["parent_id", "name"] + }, + "endpointMapping": { + "method": "POST", + "path": "/folders", + "bodyMapping": { + "name": "$name", + "parent": { "id": "$parent_id" } + } + } + }, + { + "name": "box_get_file", + "description": "Get file metadata.", + "parameters": { + "type": "object", + "properties": { + "file_id": { "type": "string", "description": "File ID." }, + "fields": { "type": "string", "description": "Fields to project." } + }, + "required": ["file_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/files/{file_id}", + "queryParams": { "fields": "$fields" } + } + }, + { + "name": "box_copy_file", + "description": "Copy a file to a different folder.", + "parameters": { + "type": "object", + "properties": { + "file_id": { "type": "string", "description": "Source file ID." }, + "parent_id": { "type": "string", "description": "Destination folder ID." }, + "name": { "type": "string", "description": "Optional new name." }, + "version": { "type": "string", "description": "Copy a specific version." } + }, + "required": ["file_id", "parent_id"] + }, + "endpointMapping": { + "method": "POST", + "path": "/files/{file_id}/copy", + "bodyMapping": { + "parent": { "id": "$parent_id" }, + "name": "$name", + "version": "$version" + } + } + }, + { + "name": "box_move_or_rename_file", + "description": "Move file to a different folder and/or rename. Update other fields too.", + "parameters": { + "type": "object", + "properties": { + "file_id": { "type": "string", "description": "File ID." }, + "parent_id": { "type": "string", "description": "New parent folder." }, + "name": { "type": "string", "description": "New name." }, + "description": { "type": "string", "description": "New description." } + }, + "required": ["file_id"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/files/{file_id}", + "bodyMapping": { + "parent": { "id": "$parent_id" }, + "name": "$name", + "description": "$description" + } + } + }, + { + "name": "box_delete_file", + "description": "Delete a file (moves to trash; restore via /files/{id}/trash endpoint).", + "parameters": { + "type": "object", + "properties": { + "file_id": { "type": "string", "description": "File ID." } + }, + "required": ["file_id"] + }, + "endpointMapping": { "method": "DELETE", "path": "/files/{file_id}" } + }, + { + "name": "box_search", + "description": "Search across content. Filter by type (file/folder/web_link), file extensions, content types, date range.", + "parameters": { + "type": "object", + "properties": { + "query": { "type": "string", "description": "Search query." }, + "type": { "type": "string", "description": "file, folder, web_link." }, + "file_extensions": { "type": "string", "description": "Comma-separated extensions." }, + "content_types": { "type": "string", "description": "Comma-separated: name, description, file_content, comments, tags." }, + "ancestor_folder_ids": { "type": "string", "description": "Comma-separated folder IDs to restrict search to." }, + "limit": { "type": "integer", "description": "Max 200." }, + "offset": { "type": "integer", "description": "0-based." } + }, + "required": ["query"] + }, + "endpointMapping": { + "method": "GET", + "path": "/search", + "queryParams": { + "query": "$query", + "type": "$type", + "file_extensions": "$file_extensions", + "content_types": "$content_types", + "ancestor_folder_ids": "$ancestor_folder_ids", + "limit": "$limit", + "offset": "$offset" + } + } + }, + { + "name": "box_create_shared_link", + "description": "Create or update a shared link on a file. Returns the shared URL.", + "parameters": { + "type": "object", + "properties": { + "file_id": { "type": "string", "description": "File ID." }, + "access": { "type": "string", "description": "open (public), company, collaborators." }, + "password": { "type": "string", "description": "Optional password." }, + "unshared_at": { "type": "string", "description": "Optional ISO 8601 expiry." }, + "can_download": { "type": "boolean", "description": "Allow downloads." } + }, + "required": ["file_id"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/files/{file_id}", + "bodyMapping": { + "shared_link": { + "access": "$access", + "password": "$password", + "unshared_at": "$unshared_at", + "permissions": { "can_download": "$can_download" } + } + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/box.live.spec.ts b/packages/backend/src/adapters/intl/box.live.spec.ts new file mode 100644 index 0000000..6b2b3a1 --- /dev/null +++ b/packages/backend/src/adapters/intl/box.live.spec.ts @@ -0,0 +1,8 @@ +import * as adapter from './box.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string } }; +describe('box adapter — static spec conformance', () => { + it('api.box.com/2.0 base URL', () => + expect(a.connector.baseUrl).toBe('https://api.box.com/2.0')); + it('OAuth2 refresh flow', () => + expect(a.connector.authType).toBe('OAUTH2')); +}); diff --git a/packages/backend/src/adapters/intl/buffer.json b/packages/backend/src/adapters/intl/buffer.json new file mode 100644 index 0000000..3ac9248 --- /dev/null +++ b/packages/backend/src/adapters/intl/buffer.json @@ -0,0 +1,184 @@ +{ + "slug": "buffer", + "name": "Buffer", + "description": "Manage Buffer (social media scheduling: channels, posts, idea queue, analytics) from any AI agent via GraphQL. 8 tools, Bearer token.", + "instructions": "This connector wraps the Buffer Publish GraphQL API (graphql.buffer.com).\n\n**Setup**:\n1. Sign in at https://publish.buffer.com → top-right avatar → **Settings → API access → Generate personal access token**.\n2. Set `BUFFER_ACCESS_TOKEN`.\n\n**Authentication**: `Authorization: Bearer ${BUFFER_ACCESS_TOKEN}`.\n\n**GraphQL-only**: Buffer's modern API is GraphQL. The auto-injected `buffer_graphql_query` / `buffer_graphql_mutation` / `buffer_graphql_schema` builtins cover anything not in the typed wrappers below.\n\n**Channel = connected social account** (Twitter, LinkedIn, Facebook, Instagram, Mastodon, TikTok, Pinterest, YouTube, Bluesky, Threads). Each has an ID; posts must specify channelId(s).\n\n**Post status**: `PENDING_APPROVAL`, `SCHEDULED`, `SENT`, `FAILED`, `DRAFT`.\n\n**Rate limits**: 60 req/min per token. 429 with `Retry-After`.\n\n**Out of scope here**: organization/team membership, billing, Pablo image editor, the Analyze (analytics) deep reports.", + "region": "intl", + "category": "social", + "icon": "buffer", + "docsUrl": "https://buffer.com/developers/api", + "requiredEnvVars": ["BUFFER_ACCESS_TOKEN"], + "connector": { + "name": "Buffer GraphQL API", + "type": "GRAPHQL", + "baseUrl": "https://graphql.buffer.com", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{BUFFER_ACCESS_TOKEN}}" + } + }, + "tools": [ + { + "name": "buffer_current_user", + "description": "Return the authenticated user with their orgs.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { + "method": "query", + "path": "{ currentUser { id email name organizations { id name } } }" + } + }, + { + "name": "buffer_list_channels", + "description": "List connected social channels for an organization.", + "parameters": { + "type": "object", + "properties": { + "organization_id": { "type": "string", "description": "Org ID." } + }, + "required": ["organization_id"] + }, + "endpointMapping": { + "method": "query", + "path": "query ListChannels($orgId: ID!) { organization(id: $orgId) { channels { id name service serviceType timezone avatar } } }", + "bodyMapping": { "variables": { "orgId": "$organization_id" } } + } + }, + { + "name": "buffer_list_posts", + "description": "List posts on a channel with status filter.", + "parameters": { + "type": "object", + "properties": { + "channel_id": { "type": "string", "description": "Channel ID." }, + "status": { "type": "string", "description": "PENDING_APPROVAL, SCHEDULED, SENT, FAILED, DRAFT." }, + "first": { "type": "integer", "description": "Page size." }, + "after": { "type": "string", "description": "Cursor." } + }, + "required": ["channel_id"] + }, + "endpointMapping": { + "method": "query", + "path": "query ListPosts($channelId: ID!, $status: PostStatus, $first: Int, $after: String) { channel(id: $channelId) { posts(status: $status, first: $first, after: $after) { edges { node { id status text dueAt sentAt sentLink media { url type } } } pageInfo { endCursor hasNextPage } } } }", + "bodyMapping": { + "variables": { + "channelId": "$channel_id", + "status": "$status", + "first": "$first", + "after": "$after" + } + } + } + }, + { + "name": "buffer_create_post", + "description": "Schedule or save a draft post on one or more channels.", + "parameters": { + "type": "object", + "properties": { + "organization_id": { "type": "string", "description": "Org ID." }, + "channel_ids": { "type": "array", "description": "Array of channel IDs." }, + "text": { "type": "string", "description": "Post text." }, + "due_at": { "type": "string", "description": "ISO 8601 schedule time. Omit for queue / draft." }, + "save_as_draft": { "type": "boolean", "description": "If true, save as draft instead of scheduling." }, + "media_urls": { "type": "array", "description": "Array of image / video URLs." }, + "link": { "type": "string", "description": "Link URL (for link-preview channels)." } + }, + "required": ["organization_id", "channel_ids", "text"] + }, + "endpointMapping": { + "method": "mutation", + "path": "mutation Create($input: CreatePostInput!) { createPost(input: $input) { post { id status } } }", + "bodyMapping": { + "variables": { + "input": { + "organizationId": "$organization_id", + "channelIds": "$channel_ids", + "text": "$text", + "dueAt": "$due_at", + "saveAsDraft": "$save_as_draft", + "mediaUrls": "$media_urls", + "link": "$link" + } + } + } + } + }, + { + "name": "buffer_update_post", + "description": "Update a scheduled post's text, schedule, or media.", + "parameters": { + "type": "object", + "properties": { + "post_id": { "type": "string", "description": "Post ID." }, + "text": { "type": "string", "description": "New text." }, + "due_at": { "type": "string", "description": "New ISO 8601 time." }, + "media_urls": { "type": "array", "description": "Replace media URLs." } + }, + "required": ["post_id"] + }, + "endpointMapping": { + "method": "mutation", + "path": "mutation Update($input: UpdatePostInput!) { updatePost(input: $input) { post { id status dueAt } } }", + "bodyMapping": { + "variables": { + "input": { + "id": "$post_id", + "text": "$text", + "dueAt": "$due_at", + "mediaUrls": "$media_urls" + } + } + } + } + }, + { + "name": "buffer_delete_post", + "description": "Delete a post.", + "parameters": { + "type": "object", + "properties": { + "post_id": { "type": "string", "description": "Post ID." } + }, + "required": ["post_id"] + }, + "endpointMapping": { + "method": "mutation", + "path": "mutation Delete($id: ID!) { deletePost(id: $id) { success } }", + "bodyMapping": { "variables": { "id": "$post_id" } } + } + }, + { + "name": "buffer_send_now", + "description": "Send a scheduled post immediately, regardless of due_at.", + "parameters": { + "type": "object", + "properties": { + "post_id": { "type": "string", "description": "Post ID." } + }, + "required": ["post_id"] + }, + "endpointMapping": { + "method": "mutation", + "path": "mutation SendNow($id: ID!) { sendPostNow(id: $id) { post { id status sentAt } } }", + "bodyMapping": { "variables": { "id": "$post_id" } } + } + }, + { + "name": "buffer_list_ideas", + "description": "List ideas from the org-wide idea queue.", + "parameters": { + "type": "object", + "properties": { + "organization_id": { "type": "string", "description": "Org ID." }, + "first": { "type": "integer", "description": "Page size." } + }, + "required": ["organization_id"] + }, + "endpointMapping": { + "method": "query", + "path": "query Ideas($orgId: ID!, $first: Int) { organization(id: $orgId) { ideas(first: $first) { edges { node { id text createdAt } } } } }", + "bodyMapping": { "variables": { "orgId": "$organization_id", "first": "$first" } } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/buffer.live.spec.ts b/packages/backend/src/adapters/intl/buffer.live.spec.ts new file mode 100644 index 0000000..130e56d --- /dev/null +++ b/packages/backend/src/adapters/intl/buffer.live.spec.ts @@ -0,0 +1,12 @@ +import * as adapter from './buffer.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; type: string; authType: string }; +}; +describe('buffer adapter — static spec conformance', () => { + it('graphql.buffer.com base URL', () => + expect(a.connector.baseUrl).toBe('https://graphql.buffer.com')); + it('GraphQL connector with Bearer auth', () => { + expect(a.connector.type).toBe('GRAPHQL'); + expect(a.connector.authType).toBe('BEARER_TOKEN'); + }); +}); diff --git a/packages/backend/src/adapters/intl/deel.json b/packages/backend/src/adapters/intl/deel.json new file mode 100644 index 0000000..317d50a --- /dev/null +++ b/packages/backend/src/adapters/intl/deel.json @@ -0,0 +1,223 @@ +{ + "slug": "deel", + "name": "Deel", + "description": "Manage Deel (global EOR / contractor platform: people, contracts, invoices, payments, organizations) from any AI agent. 10 tools, Bearer token.", + "instructions": "This connector wraps the Deel API v2 (api.letsdeel.com/rest/v2).\n\n**Setup**:\n1. Log into https://app.deel.com → top-right avatar → **Developer Center → Create API token**.\n2. Pick scopes (read/write people, contracts, invoices, etc.).\n3. Set `DEEL_API_TOKEN`.\n\n**Authentication**: `Authorization: Bearer ${DEEL_API_TOKEN}`.\n\n**Entity model**: `Person` (worker) → `Contract` (engagement: EOR, contractor, direct employee) → `Invoice` (per pay period) → `Payment` (the disbursement). Each has its own endpoint.\n\n**Pagination**: `limit` (max 250) + `offset` (0-based). Response has `total` count.\n\n**Rate limits**: 100 req/min per token. 429 with `Retry-After`.\n\n**Out of scope here**: equity grants, expense reimbursements multi-step (upload + claim), payroll-cycle adjustments, the legacy v1 endpoints.", + "region": "intl", + "category": "hr", + "icon": "deel", + "docsUrl": "https://developer.deel.com/reference/", + "requiredEnvVars": ["DEEL_API_TOKEN"], + "connector": { + "name": "Deel API v2", + "type": "REST", + "baseUrl": "https://api.letsdeel.com/rest/v2", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{DEEL_API_TOKEN}}" + } + }, + "tools": [ + { + "name": "deel_list_organizations", + "description": "List organizations the token can access.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/organizations" } + }, + { + "name": "deel_list_people", + "description": "List people (workers).", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Max 250." }, + "offset": { "type": "integer", "description": "0-based." }, + "name": { "type": "string", "description": "Name filter." }, + "email": { "type": "string", "description": "Email filter." }, + "hiring_status": { "type": "string", "description": "active, inactive." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/people", + "queryParams": { + "limit": "$limit", + "offset": "$offset", + "name": "$name", + "email": "$email", + "hiring_status": "$hiring_status" + } + } + }, + { + "name": "deel_get_person", + "description": "Get one person with full profile.", + "parameters": { + "type": "object", + "properties": { + "person_id": { "type": "string", "description": "Person hris_id (Deel public ID)." } + }, + "required": ["person_id"] + }, + "endpointMapping": { "method": "GET", "path": "/people/{person_id}" } + }, + { + "name": "deel_list_contracts", + "description": "List contracts. Filter by status (in_progress, terminated, draft), type (eor, contractor, direct_employee).", + "parameters": { + "type": "object", + "properties": { + "after_cursor": { "type": "string", "description": "Cursor pagination." }, + "limit": { "type": "integer", "description": "Max 250." }, + "types": { "type": "string", "description": "Comma-separated contract types." }, + "statuses": { "type": "string", "description": "Comma-separated statuses." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/contracts", + "queryParams": { + "after_cursor": "$after_cursor", + "limit": "$limit", + "types": "$types", + "statuses": "$statuses" + } + } + }, + { + "name": "deel_get_contract", + "description": "Get one contract with full terms.", + "parameters": { + "type": "object", + "properties": { + "contract_id": { "type": "string", "description": "Contract ID." } + }, + "required": ["contract_id"] + }, + "endpointMapping": { "method": "GET", "path": "/contracts/{contract_id}" } + }, + { + "name": "deel_terminate_contract", + "description": "Terminate a contract.", + "parameters": { + "type": "object", + "properties": { + "contract_id": { "type": "string", "description": "Contract ID." }, + "termination_date": { "type": "string", "description": "YYYY-MM-DD." }, + "reason": { "type": "string", "description": "Free-text termination reason." }, + "additional_information": { "type": "string", "description": "Optional notes." } + }, + "required": ["contract_id", "termination_date"] + }, + "endpointMapping": { + "method": "POST", + "path": "/contracts/{contract_id}/terminations", + "bodyMapping": { + "termination_date": "$termination_date", + "reason": "$reason", + "additional_information": "$additional_information" + } + } + }, + { + "name": "deel_list_invoices", + "description": "List invoices (per-contract pay period). Filter by date range, status (paid, awaiting_payment).", + "parameters": { + "type": "object", + "properties": { + "from": { "type": "string", "description": "Issued from (YYYY-MM-DD)." }, + "to": { "type": "string", "description": "Issued to." }, + "status": { "type": "string", "description": "paid, awaiting_payment, overdue." }, + "limit": { "type": "integer", "description": "Max 250." }, + "offset": { "type": "integer", "description": "0-based." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/invoices", + "queryParams": { + "from": "$from", + "to": "$to", + "status": "$status", + "limit": "$limit", + "offset": "$offset" + } + } + }, + { + "name": "deel_list_payments", + "description": "List payments (disbursements to workers).", + "parameters": { + "type": "object", + "properties": { + "from": { "type": "string", "description": "YYYY-MM-DD." }, + "to": { "type": "string", "description": "YYYY-MM-DD." }, + "limit": { "type": "integer", "description": "Max 250." }, + "offset": { "type": "integer", "description": "0-based." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/payments", + "queryParams": { + "from": "$from", + "to": "$to", + "limit": "$limit", + "offset": "$offset" + } + } + }, + { + "name": "deel_list_time_offs", + "description": "List time-off requests (for direct employees and EOR workers).", + "parameters": { + "type": "object", + "properties": { + "from": { "type": "string", "description": "YYYY-MM-DD." }, + "to": { "type": "string", "description": "YYYY-MM-DD." }, + "type": { "type": "string", "description": "Time off type." }, + "status": { "type": "string", "description": "approved, pending, rejected." }, + "limit": { "type": "integer", "description": "Max 250." }, + "offset": { "type": "integer", "description": "0-based." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/time-offs", + "queryParams": { + "from": "$from", + "to": "$to", + "type": "$type", + "status": "$status", + "limit": "$limit", + "offset": "$offset" + } + } + }, + { + "name": "deel_add_adjustment", + "description": "Add a one-off pay adjustment to a contract's invoice cycle (e.g. bonus, commission, deduction).", + "parameters": { + "type": "object", + "properties": { + "contract_id": { "type": "string", "description": "Contract ID." }, + "amount": { "type": "number", "description": "Amount (positive=add, negative=deduct)." }, + "description": { "type": "string", "description": "What it's for." }, + "date": { "type": "string", "description": "Apply on (YYYY-MM-DD)." }, + "category": { "type": "string", "description": "bonus, commission, overtime, expense, deduction, ..." } + }, + "required": ["contract_id", "amount", "description", "date"] + }, + "endpointMapping": { + "method": "POST", + "path": "/contracts/{contract_id}/adjustments", + "bodyMapping": { + "amount": "$amount", + "description": "$description", + "date": "$date", + "category": "$category" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/deel.live.spec.ts b/packages/backend/src/adapters/intl/deel.live.spec.ts new file mode 100644 index 0000000..4283a8a --- /dev/null +++ b/packages/backend/src/adapters/intl/deel.live.spec.ts @@ -0,0 +1,7 @@ +import * as adapter from './deel.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string } }; +describe('deel adapter — static spec conformance', () => { + it('api.letsdeel.com/rest/v2 base URL', () => + expect(a.connector.baseUrl).toBe('https://api.letsdeel.com/rest/v2')); + it('Bearer auth', () => expect(a.connector.authType).toBe('BEARER_TOKEN')); +}); diff --git a/packages/backend/src/adapters/intl/dropbox.json b/packages/backend/src/adapters/intl/dropbox.json new file mode 100644 index 0000000..afcc2d7 --- /dev/null +++ b/packages/backend/src/adapters/intl/dropbox.json @@ -0,0 +1,209 @@ +{ + "slug": "dropbox", + "name": "Dropbox", + "description": "Manage Dropbox (file storage: list, download, upload, move, copy, delete, search, share, metadata) from any AI agent. 10 tools, OAuth2 token.", + "instructions": "This connector wraps the Dropbox API v2 (api.dropboxapi.com).\n\n**Setup — OAuth2**:\n1. Register at https://www.dropbox.com/developers/apps → **Create app** → 'Scoped access' → 'App folder' or 'Full Dropbox' depending on need.\n2. Add the scopes you need (files.metadata.read, files.content.read, files.content.write, sharing.write, ...).\n3. Generate a **Refresh token** via OAuth flow with `token_access_type=offline`.\n4. Set `DROPBOX_CLIENT_ID`, `DROPBOX_CLIENT_SECRET`, `DROPBOX_REFRESH_TOKEN`.\n\n**Authentication**: OAuth2 refresh handled by engine. Sends `Authorization: Bearer ACCESS_TOKEN`.\n\n**Two API hosts**: most endpoints live at `api.dropboxapi.com/2`. Content endpoints (upload/download) use `content.dropboxapi.com/2` — out of scope here for upload (multipart streaming). Use the link-based pattern: have content already accessible via URL → use `dropbox_save_url` to ingest, or use external upload then create_shared_link to share.\n\n**Path format**: paths use `/Folder/Subfolder/File.txt` (case-insensitive, no leading drive). Root is empty string or `/`. For App-folder apps, paths are relative to the app folder.\n\n**Pagination**: most list endpoints use a `cursor` returned in the response. Pass back as `cursor` to continue.\n\n**Rate limits**: per-endpoint quotas, generally 100-1000 req/min. 429 with `Retry-After`.\n\n**Out of scope here**: direct file upload (multipart, separate API), Paper docs, team admin, the legacy v1 API.", + "region": "intl", + "category": "storage", + "icon": "dropbox", + "docsUrl": "https://www.dropbox.com/developers/documentation/http/documentation", + "requiredEnvVars": ["DROPBOX_CLIENT_ID", "DROPBOX_CLIENT_SECRET", "DROPBOX_REFRESH_TOKEN"], + "connector": { + "name": "Dropbox API v2", + "type": "REST", + "baseUrl": "https://api.dropboxapi.com/2", + "authType": "OAUTH2", + "authConfig": { + "clientId": "{{DROPBOX_CLIENT_ID}}", + "clientSecret": "{{DROPBOX_CLIENT_SECRET}}", + "refreshToken": "{{DROPBOX_REFRESH_TOKEN}}", + "tokenUrl": "https://api.dropboxapi.com/oauth2/token" + } + }, + "tools": [ + { + "name": "dropbox_get_current_account", + "description": "Return the authenticated user's account (account_id, name, email, country, locale, account_type).", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { + "method": "POST", + "path": "/users/get_current_account", + "bodyMapping": {} + } + }, + { + "name": "dropbox_list_folder", + "description": "List contents of a folder. Use `recursive=true` to walk subfolders.", + "parameters": { + "type": "object", + "properties": { + "path": { "type": "string", "description": "Path like '/My Folder' or '' for root." }, + "recursive": { "type": "boolean", "description": "Walk subfolders." }, + "limit": { "type": "integer", "description": "Max per page (default 2000)." }, + "include_deleted": { "type": "boolean", "description": "Include deleted entries." } + }, + "required": ["path"] + }, + "endpointMapping": { + "method": "POST", + "path": "/files/list_folder", + "bodyMapping": { + "path": "$path", + "recursive": "$recursive", + "limit": "$limit", + "include_deleted": "$include_deleted" + } + } + }, + { + "name": "dropbox_list_folder_continue", + "description": "Continue a list_folder cursor.", + "parameters": { + "type": "object", + "properties": { + "cursor": { "type": "string", "description": "Cursor from previous response." } + }, + "required": ["cursor"] + }, + "endpointMapping": { + "method": "POST", + "path": "/files/list_folder/continue", + "bodyMapping": { "cursor": "$cursor" } + } + }, + { + "name": "dropbox_get_metadata", + "description": "Get metadata for a single file/folder at a path.", + "parameters": { + "type": "object", + "properties": { + "path": { "type": "string", "description": "Path." }, + "include_deleted": { "type": "boolean", "description": "Include if deleted." } + }, + "required": ["path"] + }, + "endpointMapping": { + "method": "POST", + "path": "/files/get_metadata", + "bodyMapping": { "path": "$path", "include_deleted": "$include_deleted" } + } + }, + { + "name": "dropbox_search", + "description": "Search files by query within a path. Filter by file extension, category.", + "parameters": { + "type": "object", + "properties": { + "query": { "type": "string", "description": "Search query." }, + "path": { "type": "string", "description": "Restrict to this folder ('' for root)." }, + "max_results": { "type": "integer", "description": "Default 100, max 1000." }, + "file_extensions": { "type": "array", "description": "Array of extensions like ['pdf','docx']." }, + "file_categories": { "type": "array", "description": "Array: image, document, pdf, spreadsheet, presentation, audio, video, folder, ..." } + }, + "required": ["query"] + }, + "endpointMapping": { + "method": "POST", + "path": "/files/search_v2", + "bodyMapping": { + "query": "$query", + "options": { + "path": "$path", + "max_results": "$max_results", + "file_extensions": "$file_extensions", + "file_categories": "$file_categories" + } + } + } + }, + { + "name": "dropbox_create_folder", + "description": "Create a folder at a path.", + "parameters": { + "type": "object", + "properties": { + "path": { "type": "string", "description": "Folder path to create." }, + "autorename": { "type": "boolean", "description": "If true, append a counter on conflict." } + }, + "required": ["path"] + }, + "endpointMapping": { + "method": "POST", + "path": "/files/create_folder_v2", + "bodyMapping": { "path": "$path", "autorename": "$autorename" } + } + }, + { + "name": "dropbox_move", + "description": "Move a file/folder to a new path.", + "parameters": { + "type": "object", + "properties": { + "from_path": { "type": "string", "description": "Source path." }, + "to_path": { "type": "string", "description": "Destination path." }, + "autorename": { "type": "boolean", "description": "Append counter on conflict." } + }, + "required": ["from_path", "to_path"] + }, + "endpointMapping": { + "method": "POST", + "path": "/files/move_v2", + "bodyMapping": { + "from_path": "$from_path", + "to_path": "$to_path", + "autorename": "$autorename" + } + } + }, + { + "name": "dropbox_copy", + "description": "Copy a file/folder to a new path.", + "parameters": { + "type": "object", + "properties": { + "from_path": { "type": "string", "description": "Source path." }, + "to_path": { "type": "string", "description": "Destination path." } + }, + "required": ["from_path", "to_path"] + }, + "endpointMapping": { + "method": "POST", + "path": "/files/copy_v2", + "bodyMapping": { "from_path": "$from_path", "to_path": "$to_path" } + } + }, + { + "name": "dropbox_delete", + "description": "Delete a file/folder.", + "parameters": { + "type": "object", + "properties": { + "path": { "type": "string", "description": "Path to delete." } + }, + "required": ["path"] + }, + "endpointMapping": { + "method": "POST", + "path": "/files/delete_v2", + "bodyMapping": { "path": "$path" } + } + }, + { + "name": "dropbox_create_shared_link", + "description": "Create a public shared link for a file/folder.", + "parameters": { + "type": "object", + "properties": { + "path": { "type": "string", "description": "Path to share." }, + "settings": { "type": "object", "description": "{requested_visibility:'public'|'team_only'|'password', link_password:'...', expires:''}." } + }, + "required": ["path"] + }, + "endpointMapping": { + "method": "POST", + "path": "/sharing/create_shared_link_with_settings", + "bodyMapping": { "path": "$path", "settings": "$settings" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/dropbox.live.spec.ts b/packages/backend/src/adapters/intl/dropbox.live.spec.ts new file mode 100644 index 0000000..d42ab7a --- /dev/null +++ b/packages/backend/src/adapters/intl/dropbox.live.spec.ts @@ -0,0 +1,8 @@ +import * as adapter from './dropbox.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string } }; +describe('dropbox adapter — static spec conformance', () => { + it('api.dropboxapi.com/2 base URL', () => + expect(a.connector.baseUrl).toBe('https://api.dropboxapi.com/2')); + it('OAuth2 refresh flow', () => + expect(a.connector.authType).toBe('OAUTH2')); +}); diff --git a/packages/backend/src/adapters/intl/flutterwave.json b/packages/backend/src/adapters/intl/flutterwave.json new file mode 100644 index 0000000..59c70fa --- /dev/null +++ b/packages/backend/src/adapters/intl/flutterwave.json @@ -0,0 +1,236 @@ +{ + "slug": "flutterwave", + "name": "Flutterwave", + "description": "Manage Flutterwave (Africa payments: payments, customers, subscriptions, transfers, refunds, payouts) from any AI agent. 10 tools, Bearer secret.", + "instructions": "This connector wraps the Flutterwave API v3 (api.flutterwave.com/v3).\n\n**Setup**:\n1. Sign in at https://app.flutterwave.com → **Settings → API**.\n2. Note your **Secret Key** (`FLWSECK_TEST-...` for test, `FLWSECK-...` for live) AND public key (used client-side).\n3. Set `FLUTTERWAVE_SECRET_KEY`.\n\n**Authentication**: `Authorization: Bearer ${FLUTTERWAVE_SECRET_KEY}`.\n\n**Test vs Live**: secret key prefix determines mode. Test transactions never touch real cards.\n\n**Amounts**: Flutterwave uses major-unit decimal floats (₦1.50 = `1.50`), unlike Paystack — pay attention.\n\n**Charge flow**: server creates a payment → returns a hosted checkout `link`. Customer pays → Flutterwave redirects back with `transaction_id`. Verify via `flutterwave_verify_transaction`.\n\n**Pagination**: `page` (1-based) + `per_page` (max 200).\n\n**Rate limits**: 100 req/sec per key. 429 → exponential.\n\n**Out of scope here**: virtual cards, BVN verification, settlements, dispute management.", + "region": "intl", + "category": "payments", + "icon": "flutterwave", + "docsUrl": "https://developer.flutterwave.com/reference/", + "requiredEnvVars": ["FLUTTERWAVE_SECRET_KEY"], + "connector": { + "name": "Flutterwave API v3", + "type": "REST", + "baseUrl": "https://api.flutterwave.com/v3", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{FLUTTERWAVE_SECRET_KEY}}" + } + }, + "tools": [ + { + "name": "flutterwave_create_payment", + "description": "Create a payment — returns a hosted checkout URL (`data.link`) to redirect the customer to.", + "parameters": { + "type": "object", + "properties": { + "tx_ref": { "type": "string", "description": "Your unique transaction reference." }, + "amount": { "type": "number", "description": "Amount in major units (e.g. 1.50)." }, + "currency": { "type": "string", "description": "NGN, USD, GHS, ZAR, KES, UGX, RWF, TZS, ..." }, + "redirect_url": { "type": "string", "description": "Where to send the customer after payment." }, + "customer": { "type": "object", "description": "{email, name, phonenumber}." }, + "customizations": { "type": "object", "description": "{title, description, logo} for the hosted page." }, + "payment_options": { "type": "string", "description": "Comma-separated methods: card, banktransfer, mobilemoneyghana, mpesa, ussd, ..." }, + "meta": { "type": "object", "description": "Custom metadata." } + }, + "required": ["tx_ref", "amount", "currency", "redirect_url", "customer"] + }, + "endpointMapping": { + "method": "POST", + "path": "/payments", + "bodyMapping": { + "tx_ref": "$tx_ref", + "amount": "$amount", + "currency": "$currency", + "redirect_url": "$redirect_url", + "customer": "$customer", + "customizations": "$customizations", + "payment_options": "$payment_options", + "meta": "$meta" + } + } + }, + { + "name": "flutterwave_verify_transaction", + "description": "Verify a transaction by Flutterwave's `transaction_id` (NOT your tx_ref). Always run this after the customer returns from the hosted page.", + "parameters": { + "type": "object", + "properties": { + "transaction_id": { "type": "integer", "description": "Numeric transaction_id returned by Flutterwave." } + }, + "required": ["transaction_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/transactions/{transaction_id}/verify" + } + }, + { + "name": "flutterwave_verify_by_reference", + "description": "Verify a transaction by your `tx_ref`. Use this when you don't have the Flutterwave transaction_id.", + "parameters": { + "type": "object", + "properties": { + "tx_ref": { "type": "string", "description": "Your transaction reference." } + }, + "required": ["tx_ref"] + }, + "endpointMapping": { + "method": "GET", + "path": "/transactions/verify_by_reference", + "queryParams": { "tx_ref": "$tx_ref" } + } + }, + { + "name": "flutterwave_list_transactions", + "description": "List transactions.", + "parameters": { + "type": "object", + "properties": { + "from": { "type": "string", "description": "YYYY-MM-DD." }, + "to": { "type": "string", "description": "YYYY-MM-DD." }, + "status": { "type": "string", "description": "successful, failed, pending." }, + "customer_email": { "type": "string", "description": "Filter by email." }, + "page": { "type": "integer", "description": "1-based." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/transactions", + "queryParams": { + "from": "$from", + "to": "$to", + "status": "$status", + "customer_email": "$customer_email", + "page": "$page" + } + } + }, + { + "name": "flutterwave_create_subscription_plan", + "description": "Create a recurring payment plan.", + "parameters": { + "type": "object", + "properties": { + "amount": { "type": "number", "description": "Amount in major units." }, + "name": { "type": "string", "description": "Plan name." }, + "interval": { "type": "string", "description": "hourly, daily, weekly, monthly, quarterly, bi-annually, annually." }, + "duration": { "type": "integer", "description": "Number of times to charge. Omit for indefinite." }, + "currency": { "type": "string", "description": "ISO 4217." } + }, + "required": ["amount", "name", "interval"] + }, + "endpointMapping": { + "method": "POST", + "path": "/payment-plans", + "bodyMapping": { + "amount": "$amount", + "name": "$name", + "interval": "$interval", + "duration": "$duration", + "currency": "$currency" + } + } + }, + { + "name": "flutterwave_list_subscriptions", + "description": "List subscriptions.", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "Filter by subscriber email." }, + "from": { "type": "string", "description": "YYYY-MM-DD." }, + "to": { "type": "string", "description": "YYYY-MM-DD." }, + "page": { "type": "integer", "description": "1-based." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/subscriptions", + "queryParams": { + "email": "$email", + "from": "$from", + "to": "$to", + "page": "$page" + } + } + }, + { + "name": "flutterwave_cancel_subscription", + "description": "Cancel a subscription.", + "parameters": { + "type": "object", + "properties": { + "subscription_id": { "type": "integer", "description": "Subscription ID." } + }, + "required": ["subscription_id"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/subscriptions/{subscription_id}/cancel" + } + }, + { + "name": "flutterwave_initiate_transfer", + "description": "Initiate a transfer (payout to a bank account).", + "parameters": { + "type": "object", + "properties": { + "account_bank": { "type": "string", "description": "Bank code (from /banks endpoint)." }, + "account_number": { "type": "string", "description": "Account number." }, + "amount": { "type": "number", "description": "Amount in major units." }, + "currency": { "type": "string", "description": "ISO 4217." }, + "narration": { "type": "string", "description": "Description shown on statement." }, + "reference": { "type": "string", "description": "Your unique reference." }, + "beneficiary_name": { "type": "string", "description": "Beneficiary's full name." } + }, + "required": ["account_bank", "account_number", "amount", "currency"] + }, + "endpointMapping": { + "method": "POST", + "path": "/transfers", + "bodyMapping": { + "account_bank": "$account_bank", + "account_number": "$account_number", + "amount": "$amount", + "currency": "$currency", + "narration": "$narration", + "reference": "$reference", + "beneficiary_name": "$beneficiary_name" + } + } + }, + { + "name": "flutterwave_refund_transaction", + "description": "Refund a transaction.", + "parameters": { + "type": "object", + "properties": { + "transaction_id": { "type": "integer", "description": "Transaction ID to refund." }, + "amount": { "type": "number", "description": "Amount in major units (omit for full refund)." } + }, + "required": ["transaction_id"] + }, + "endpointMapping": { + "method": "POST", + "path": "/transactions/{transaction_id}/refund", + "bodyMapping": { "amount": "$amount" } + } + }, + { + "name": "flutterwave_list_banks", + "description": "List supported banks for a country. Use to find bank codes for transfers.", + "parameters": { + "type": "object", + "properties": { + "country": { "type": "string", "description": "ISO 3166-1 alpha-2: NG, GH, KE, UG, ZA, ..." } + }, + "required": ["country"] + }, + "endpointMapping": { + "method": "GET", + "path": "/banks/{country}" + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/flutterwave.live.spec.ts b/packages/backend/src/adapters/intl/flutterwave.live.spec.ts new file mode 100644 index 0000000..2dd638c --- /dev/null +++ b/packages/backend/src/adapters/intl/flutterwave.live.spec.ts @@ -0,0 +1,8 @@ +import * as adapter from './flutterwave.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string } }; +describe('flutterwave adapter — static spec conformance', () => { + it('api.flutterwave.com/v3 base URL', () => + expect(a.connector.baseUrl).toBe('https://api.flutterwave.com/v3')); + it('Bearer auth (secret key)', () => + expect(a.connector.authType).toBe('BEARER_TOKEN')); +}); diff --git a/packages/backend/src/adapters/intl/freshbooks.json b/packages/backend/src/adapters/intl/freshbooks.json new file mode 100644 index 0000000..38df840 --- /dev/null +++ b/packages/backend/src/adapters/intl/freshbooks.json @@ -0,0 +1,228 @@ +{ + "slug": "freshbooks", + "name": "FreshBooks", + "description": "Manage FreshBooks (invoices, clients, expenses, time entries, payments) from any AI agent. 9 tools, OAuth2 auth.", + "instructions": "This connector wraps the FreshBooks New API (api.freshbooks.com/accounting).\n\n**Setup — OAuth2 refresh flow**:\n1. Go to https://my.freshbooks.com/#/developer → **Create an App**. Set redirect URI to your callback.\n2. Run the auth flow: `https://my.freshbooks.com/oauth/authorize?response_type=code&client_id=...&redirect_uri=...&scope=user:profile:read user:clients:read user:invoices:read user:invoices:write user:expenses:read user:time_entries:read user:payments:read user:payments:write`.\n3. Exchange the code at `https://api.freshbooks.com/auth/oauth/token` for access + refresh tokens.\n4. **Account ID**: the FreshBooks API requires an Account ID (`bzz...`) on most endpoints — find it via `GET /auth/api/v1/users/me` (the `accountid` of your first business). Set `FRESHBOOKS_ACCOUNT_ID`.\n5. Set `FRESHBOOKS_CLIENT_ID`, `FRESHBOOKS_CLIENT_SECRET`, `FRESHBOOKS_REFRESH_TOKEN`, `FRESHBOOKS_ACCOUNT_ID`.\n\n**Authentication**: OAuth2 with the engine's refresh handling. Access token sent as `Bearer`. Account ID goes in the URL path on most endpoints — substituted via runtime envVar.\n\n**Two-account-types**: classic 'accounting' (US) and 'projects' (international, the new product). This adapter covers the accounting endpoints.\n\n**Response shape**: FreshBooks wraps everything in `{response: {result: {: [...]}}}`. The adapter does not unwrap automatically — handle in the caller.\n\n**Pagination**: `page` (1-based) + `per_page` (max 100). Cursor on some newer endpoints.\n\n**Rate limits**: 30 req/min per token, 3600/hr. 429 → exponential.\n\n**Out of scope here**: reports, journal entries, project tracking (separate `/projects` API).", + "region": "intl", + "category": "accounting", + "icon": "freshbooks", + "docsUrl": "https://www.freshbooks.com/api/start", + "requiredEnvVars": ["FRESHBOOKS_CLIENT_ID", "FRESHBOOKS_CLIENT_SECRET", "FRESHBOOKS_REFRESH_TOKEN", "FRESHBOOKS_ACCOUNT_ID"], + "connector": { + "name": "FreshBooks New API", + "type": "REST", + "baseUrl": "https://api.freshbooks.com", + "authType": "OAUTH2", + "authConfig": { + "clientId": "{{FRESHBOOKS_CLIENT_ID}}", + "clientSecret": "{{FRESHBOOKS_CLIENT_SECRET}}", + "refreshToken": "{{FRESHBOOKS_REFRESH_TOKEN}}", + "tokenUrl": "https://api.freshbooks.com/auth/oauth/token" + } + }, + "tools": [ + { + "name": "freshbooks_me", + "description": "Return the authenticated user with business memberships and their account IDs.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/auth/api/v1/users/me" } + }, + { + "name": "freshbooks_list_clients", + "description": "List clients in an account.", + "parameters": { + "type": "object", + "properties": { + "page": { "type": "integer", "description": "1-based." }, + "per_page": { "type": "integer", "description": "Max 100." }, + "search_email": { "type": "string", "description": "Filter by email substring." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/accounting/account/{FRESHBOOKS_ACCOUNT_ID}/users/clients", + "queryParams": { + "page": "$page", + "per_page": "$per_page", + "search[email_like]": "$search_email" + } + } + }, + { + "name": "freshbooks_create_client", + "description": "Create a client.", + "parameters": { + "type": "object", + "properties": { + "fname": { "type": "string", "description": "First name." }, + "lname": { "type": "string", "description": "Last name." }, + "organization": { "type": "string", "description": "Company name." }, + "email": { "type": "string", "description": "Email." }, + "currency_code": { "type": "string", "description": "ISO 4217." } + }, + "required": ["email"] + }, + "endpointMapping": { + "method": "POST", + "path": "/accounting/account/{FRESHBOOKS_ACCOUNT_ID}/users/clients", + "bodyMapping": { + "client": { + "fname": "$fname", + "lname": "$lname", + "organization": "$organization", + "email": "$email", + "currency_code": "$currency_code" + } + } + } + }, + { + "name": "freshbooks_list_invoices", + "description": "List invoices. Filter by client, status, date range. Status codes: 1=Draft, 2=Sent, 3=Viewed, 4=Paid, 5=AutoPaid, 6=Retry, 7=Failed, 8=Partial, 9=Disputed.", + "parameters": { + "type": "object", + "properties": { + "clientid": { "type": "integer", "description": "Filter by client ID." }, + "status": { "type": "integer", "description": "Status code." }, + "date_from": { "type": "string", "description": "YYYY-MM-DD." }, + "date_to": { "type": "string", "description": "YYYY-MM-DD." }, + "page": { "type": "integer", "description": "1-based." }, + "per_page": { "type": "integer", "description": "Max 100." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/accounting/account/{FRESHBOOKS_ACCOUNT_ID}/invoices/invoices", + "queryParams": { + "search[clientid]": "$clientid", + "search[status]": "$status", + "search[date_min]": "$date_from", + "search[date_max]": "$date_to", + "page": "$page", + "per_page": "$per_page" + } + } + }, + { + "name": "freshbooks_get_invoice", + "description": "Get a single invoice with all lines + payments.", + "parameters": { + "type": "object", + "properties": { + "invoice_id": { "type": "integer", "description": "Invoice ID." } + }, + "required": ["invoice_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/accounting/account/{FRESHBOOKS_ACCOUNT_ID}/invoices/invoices/{invoice_id}" + } + }, + { + "name": "freshbooks_create_invoice", + "description": "Create an invoice. `lines` is an array of line items {name, qty, unit_cost, type:0|1, description}.", + "parameters": { + "type": "object", + "properties": { + "customerid": { "type": "integer", "description": "Client ID." }, + "create_date": { "type": "string", "description": "YYYY-MM-DD." }, + "lines": { "type": "array", "description": "Array of line items." }, + "notes": { "type": "string", "description": "Invoice notes." }, + "currency_code": { "type": "string", "description": "ISO 4217." }, + "terms": { "type": "string", "description": "Payment terms text." } + }, + "required": ["customerid", "create_date", "lines"] + }, + "endpointMapping": { + "method": "POST", + "path": "/accounting/account/{FRESHBOOKS_ACCOUNT_ID}/invoices/invoices", + "bodyMapping": { + "invoice": { + "customerid": "$customerid", + "create_date": "$create_date", + "lines": "$lines", + "notes": "$notes", + "currency_code": "$currency_code", + "terms": "$terms" + } + } + } + }, + { + "name": "freshbooks_list_expenses", + "description": "List expenses.", + "parameters": { + "type": "object", + "properties": { + "date_from": { "type": "string", "description": "YYYY-MM-DD." }, + "date_to": { "type": "string", "description": "YYYY-MM-DD." }, + "categoryid": { "type": "integer", "description": "Filter by category." }, + "page": { "type": "integer", "description": "1-based." }, + "per_page": { "type": "integer", "description": "Max 100." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/accounting/account/{FRESHBOOKS_ACCOUNT_ID}/expenses/expenses", + "queryParams": { + "search[date_min]": "$date_from", + "search[date_max]": "$date_to", + "search[categoryid]": "$categoryid", + "page": "$page", + "per_page": "$per_page" + } + } + }, + { + "name": "freshbooks_list_payments", + "description": "List payments received against invoices.", + "parameters": { + "type": "object", + "properties": { + "invoiceid": { "type": "integer", "description": "Filter by invoice." }, + "clientid": { "type": "integer", "description": "Filter by client." }, + "page": { "type": "integer", "description": "1-based." }, + "per_page": { "type": "integer", "description": "Max 100." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/accounting/account/{FRESHBOOKS_ACCOUNT_ID}/payments/payments", + "queryParams": { + "search[invoiceid]": "$invoiceid", + "search[clientid]": "$clientid", + "page": "$page", + "per_page": "$per_page" + } + } + }, + { + "name": "freshbooks_record_payment", + "description": "Record a payment against an invoice.", + "parameters": { + "type": "object", + "properties": { + "invoiceid": { "type": "integer", "description": "Invoice ID." }, + "amount": { "type": "object", "description": "{amount: '100.00', code: 'USD'}." }, + "date": { "type": "string", "description": "YYYY-MM-DD." }, + "type": { "type": "string", "description": "Bank Transfer, Cash, Check, Credit Card, ..." }, + "note": { "type": "string", "description": "Free-text." } + }, + "required": ["invoiceid", "amount", "date", "type"] + }, + "endpointMapping": { + "method": "POST", + "path": "/accounting/account/{FRESHBOOKS_ACCOUNT_ID}/payments/payments", + "bodyMapping": { + "payment": { + "invoiceid": "$invoiceid", + "amount": "$amount", + "date": "$date", + "type": "$type", + "note": "$note" + } + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/freshbooks.live.spec.ts b/packages/backend/src/adapters/intl/freshbooks.live.spec.ts new file mode 100644 index 0000000..faafd37 --- /dev/null +++ b/packages/backend/src/adapters/intl/freshbooks.live.spec.ts @@ -0,0 +1,8 @@ +import * as adapter from './freshbooks.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string } }; +describe('freshbooks adapter — static spec conformance', () => { + it('api.freshbooks.com base URL', () => + expect(a.connector.baseUrl).toBe('https://api.freshbooks.com')); + it('OAuth2 refresh-token flow', () => + expect(a.connector.authType).toBe('OAUTH2')); +}); diff --git a/packages/backend/src/adapters/intl/freshchat.json b/packages/backend/src/adapters/intl/freshchat.json new file mode 100644 index 0000000..88e96be --- /dev/null +++ b/packages/backend/src/adapters/intl/freshchat.json @@ -0,0 +1,191 @@ +{ + "slug": "freshchat", + "name": "Freshchat", + "description": "Manage Freshchat (live chat + messaging: users, conversations, messages, channels, agents) from any AI agent. 9 tools, Bearer token.", + "instructions": "This connector wraps the Freshchat API v2 (per-region — api.freshchat.com/v2).\n\n**Setup**:\n1. Log into your Freshchat portal → **⚙️ → Account Settings → API Tokens → Generate Token**.\n2. Note your region — Freshchat hosts data per region: `api.freshchat.com` (US), `api.eu.freshchat.com` (EU), `api.au.freshchat.com` (AU), `api.in.freshchat.com` (IN). Change baseUrl to match.\n3. Set `FRESHCHAT_API_TOKEN`.\n\n**Authentication**: `Authorization: Bearer ${FRESHCHAT_API_TOKEN}`.\n\n**Conversation model**: `User → Conversation → Messages[]`. A conversation is associated with at most one user; messages carry actor + actor_type (user, agent, system).\n\n**Pagination**: `page` (1-based) + `items_per_page` (default 25, max 100).\n\n**Rate limits**: 60 req/min per token. 429 with `Retry-After`.\n\n**Out of scope here**: bot flow editor, IntelliAssign automation, billing.", + "region": "intl", + "category": "support", + "icon": "freshchat", + "docsUrl": "https://developers.freshchat.com/api/", + "requiredEnvVars": ["FRESHCHAT_API_TOKEN"], + "connector": { + "name": "Freshchat API v2", + "type": "REST", + "baseUrl": "https://api.freshchat.com/v2", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{FRESHCHAT_API_TOKEN}}" + } + }, + "tools": [ + { + "name": "freshchat_list_users", + "description": "List users (end-customers). Filter by email or reference_id.", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "Filter by email." }, + "reference_id": { "type": "string", "description": "External system reference ID." }, + "page": { "type": "integer", "description": "1-based." }, + "items_per_page": { "type": "integer", "description": "Max 100." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/users", + "queryParams": { + "email": "$email", + "reference_id": "$reference_id", + "page": "$page", + "items_per_page": "$items_per_page" + } + } + }, + { + "name": "freshchat_get_user", + "description": "Get one user by ID with profile + properties + tags.", + "parameters": { + "type": "object", + "properties": { + "user_id": { "type": "string", "description": "User ID." } + }, + "required": ["user_id"] + }, + "endpointMapping": { "method": "GET", "path": "/users/{user_id}" } + }, + { + "name": "freshchat_create_user", + "description": "Create an end-user.", + "parameters": { + "type": "object", + "properties": { + "first_name": { "type": "string", "description": "First name." }, + "last_name": { "type": "string", "description": "Last name." }, + "email": { "type": "string", "description": "Email." }, + "phone": { "type": "string", "description": "Phone (E.164)." }, + "reference_id": { "type": "string", "description": "External ID." }, + "properties": { "type": "array", "description": "Array of {name, value} custom properties." } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/users", + "bodyMapping": { + "first_name": "$first_name", + "last_name": "$last_name", + "email": "$email", + "phone": "$phone", + "reference_id": "$reference_id", + "properties": "$properties" + } + } + }, + { + "name": "freshchat_list_conversations", + "description": "List conversations of a user.", + "parameters": { + "type": "object", + "properties": { + "user_id": { "type": "string", "description": "User ID." }, + "items_per_page": { "type": "integer", "description": "Max 100." }, + "page": { "type": "integer", "description": "1-based." } + }, + "required": ["user_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/users/{user_id}/conversations", + "queryParams": { "items_per_page": "$items_per_page", "page": "$page" } + } + }, + { + "name": "freshchat_get_conversation", + "description": "Get one conversation with status + assigned agent + recent messages.", + "parameters": { + "type": "object", + "properties": { + "conversation_id": { "type": "string", "description": "Conversation ID." } + }, + "required": ["conversation_id"] + }, + "endpointMapping": { "method": "GET", "path": "/conversations/{conversation_id}" } + }, + { + "name": "freshchat_send_message", + "description": "Post a message into a conversation. `actor_type` = 'agent' for staff replies.", + "parameters": { + "type": "object", + "properties": { + "conversation_id": { "type": "string", "description": "Conversation ID." }, + "message_parts": { "type": "array", "description": "Array of {text:{content:'...'}}." }, + "actor_id": { "type": "string", "description": "Agent or user ID sending." }, + "actor_type": { "type": "string", "description": "'agent' or 'user'." }, + "message_type": { "type": "string", "description": "normal, private, system." } + }, + "required": ["conversation_id", "message_parts", "actor_id", "actor_type"] + }, + "endpointMapping": { + "method": "POST", + "path": "/conversations/{conversation_id}/messages", + "bodyMapping": { + "message_parts": "$message_parts", + "actor_id": "$actor_id", + "actor_type": "$actor_type", + "message_type": "$message_type" + } + } + }, + { + "name": "freshchat_update_conversation", + "description": "Update a conversation's status, assigned agent, group, or tags.", + "parameters": { + "type": "object", + "properties": { + "conversation_id": { "type": "string", "description": "Conversation ID." }, + "status": { "type": "string", "description": "new, assigned, resolved, reopened." }, + "assigned_agent_id": { "type": "string", "description": "Agent to assign." }, + "assigned_group_id": { "type": "string", "description": "Group to assign." }, + "properties": { "type": "object", "description": "Custom properties." } + }, + "required": ["conversation_id"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/conversations/{conversation_id}", + "bodyMapping": { + "status": "$status", + "assigned_agent_id": "$assigned_agent_id", + "assigned_group_id": "$assigned_group_id", + "properties": "$properties" + } + } + }, + { + "name": "freshchat_list_agents", + "description": "List agents (staff).", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "Email filter." }, + "page": { "type": "integer", "description": "1-based." }, + "items_per_page": { "type": "integer", "description": "Max 100." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/agents", + "queryParams": { + "email": "$email", + "page": "$page", + "items_per_page": "$items_per_page" + } + } + }, + { + "name": "freshchat_list_channels", + "description": "List channels (topics) — the conversation routing targets.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/channels" } + } + ] +} diff --git a/packages/backend/src/adapters/intl/freshchat.live.spec.ts b/packages/backend/src/adapters/intl/freshchat.live.spec.ts new file mode 100644 index 0000000..f5293f0 --- /dev/null +++ b/packages/backend/src/adapters/intl/freshchat.live.spec.ts @@ -0,0 +1,7 @@ +import * as adapter from './freshchat.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string } }; +describe('freshchat adapter — static spec conformance', () => { + it('api.freshchat.com/v2 base URL (US region default)', () => + expect(a.connector.baseUrl).toBe('https://api.freshchat.com/v2')); + it('Bearer auth', () => expect(a.connector.authType).toBe('BEARER_TOKEN')); +}); diff --git a/packages/backend/src/adapters/intl/gocardless.json b/packages/backend/src/adapters/intl/gocardless.json new file mode 100644 index 0000000..bc63b95 --- /dev/null +++ b/packages/backend/src/adapters/intl/gocardless.json @@ -0,0 +1,284 @@ +{ + "slug": "gocardless", + "name": "GoCardless", + "description": "Manage GoCardless (direct-debit payment processing: customers, mandates, payments, subscriptions, payouts, refunds) from any AI agent. 10 tools, Bearer token.", + "instructions": "This connector wraps the GoCardless API (per-environment).\n\n**Setup**:\n1. Sign in at https://manage.gocardless.com → top-right avatar → **Developers → API access → Create**.\n2. Pick environment: `api.gocardless.com` (live) or `api-sandbox.gocardless.com` (sandbox).\n3. Set `GOCARDLESS_ACCESS_TOKEN`.\n\n**Authentication**: `Authorization: Bearer ${GOCARDLESS_ACCESS_TOKEN}` + `GoCardless-Version: 2015-07-06` (required).\n\n**Entity model**: `Customer → BankAccount → Mandate → Payment(s)` and/or `Subscription → recurring Payments`. Once a mandate is active you can charge it.\n\n**Idempotency keys**: write endpoints accept an `Idempotency-Key` header to safely retry. Best practice: pass a UUID per logical operation.\n\n**Pagination**: cursor-based via `before` / `after` query params. `limit` (max 500).\n\n**Rate limits**: 1000 req/min per token. 429 with `Retry-After`.\n\n**Out of scope here**: bank-account verification, mandate import via CSV, customer-notification webhooks setup, the Connect (partner OAuth) flow.", + "region": "intl", + "category": "payments", + "icon": "gocardless", + "docsUrl": "https://developer.gocardless.com/api-reference/", + "requiredEnvVars": ["GOCARDLESS_ACCESS_TOKEN"], + "connector": { + "name": "GoCardless API", + "type": "REST", + "baseUrl": "https://api.gocardless.com", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{GOCARDLESS_ACCESS_TOKEN}}" + }, + "headers": { + "GoCardless-Version": "2015-07-06" + } + }, + "tools": [ + { + "name": "gocardless_list_customers", + "description": "List customers.", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Max 500." }, + "after": { "type": "string", "description": "Cursor — next page." }, + "before": { "type": "string", "description": "Cursor — prev page." }, + "created_at_gt": { "type": "string", "description": "ISO 8601 — created after." }, + "created_at_lt": { "type": "string", "description": "ISO 8601." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/customers", + "queryParams": { + "limit": "$limit", + "after": "$after", + "before": "$before", + "created_at[gt]": "$created_at_gt", + "created_at[lt]": "$created_at_lt" + } + } + }, + { + "name": "gocardless_create_customer", + "description": "Create a customer.", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "Customer email." }, + "given_name": { "type": "string", "description": "Given/first name." }, + "family_name": { "type": "string", "description": "Family/last name." }, + "company_name": { "type": "string", "description": "Company (instead of names)." }, + "address_line1": { "type": "string", "description": "Address line 1." }, + "city": { "type": "string", "description": "City." }, + "postal_code": { "type": "string", "description": "Postcode." }, + "country_code": { "type": "string", "description": "ISO 3166-1 alpha-2." }, + "language": { "type": "string", "description": "ISO 639-1." } + }, + "required": ["country_code"] + }, + "endpointMapping": { + "method": "POST", + "path": "/customers", + "bodyMapping": { + "customers": { + "email": "$email", + "given_name": "$given_name", + "family_name": "$family_name", + "company_name": "$company_name", + "address_line1": "$address_line1", + "city": "$city", + "postal_code": "$postal_code", + "country_code": "$country_code", + "language": "$language" + } + } + } + }, + { + "name": "gocardless_list_mandates", + "description": "List mandates (the recurring authorisation to debit a customer's bank).", + "parameters": { + "type": "object", + "properties": { + "customer": { "type": "string", "description": "Filter by customer ID." }, + "status": { "type": "string", "description": "pending_customer_approval, pending_submission, submitted, active, failed, cancelled, expired, consumed." }, + "limit": { "type": "integer", "description": "Max 500." }, + "after": { "type": "string", "description": "Cursor." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/mandates", + "queryParams": { + "customer": "$customer", + "status": "$status", + "limit": "$limit", + "after": "$after" + } + } + }, + { + "name": "gocardless_get_mandate", + "description": "Get one mandate.", + "parameters": { + "type": "object", + "properties": { + "mandate_id": { "type": "string", "description": "Mandate ID (e.g. MD0000…)." } + }, + "required": ["mandate_id"] + }, + "endpointMapping": { "method": "GET", "path": "/mandates/{mandate_id}" } + }, + { + "name": "gocardless_list_payments", + "description": "List payments.", + "parameters": { + "type": "object", + "properties": { + "customer": { "type": "string", "description": "Filter by customer." }, + "mandate": { "type": "string", "description": "Filter by mandate." }, + "subscription": { "type": "string", "description": "Filter by subscription." }, + "status": { "type": "string", "description": "pending_customer_approval, pending_submission, submitted, confirmed, paid_out, cancelled, customer_approval_denied, failed, charged_back." }, + "limit": { "type": "integer", "description": "Max 500." }, + "after": { "type": "string", "description": "Cursor." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/payments", + "queryParams": { + "customer": "$customer", + "mandate": "$mandate", + "subscription": "$subscription", + "status": "$status", + "limit": "$limit", + "after": "$after" + } + } + }, + { + "name": "gocardless_create_payment", + "description": "Create a one-off payment against a mandate.", + "parameters": { + "type": "object", + "properties": { + "amount": { "type": "integer", "description": "Amount in minor units (cents/pence)." }, + "currency": { "type": "string", "description": "ISO 4217 (GBP, EUR, USD, AUD, NZD, SEK, DKK, CAD)." }, + "mandate_id": { "type": "string", "description": "Mandate ID to debit." }, + "description": { "type": "string", "description": "Description shown on statement." }, + "reference": { "type": "string", "description": "Reference for your records." }, + "charge_date": { "type": "string", "description": "YYYY-MM-DD when to collect." } + }, + "required": ["amount", "currency", "mandate_id"] + }, + "endpointMapping": { + "method": "POST", + "path": "/payments", + "bodyMapping": { + "payments": { + "amount": "$amount", + "currency": "$currency", + "links": { "mandate": "$mandate_id" }, + "description": "$description", + "reference": "$reference", + "charge_date": "$charge_date" + } + } + } + }, + { + "name": "gocardless_list_subscriptions", + "description": "List subscriptions (recurring payment schedules).", + "parameters": { + "type": "object", + "properties": { + "customer": { "type": "string", "description": "Filter by customer." }, + "status": { "type": "string", "description": "active, paused, cancelled, finished." }, + "limit": { "type": "integer", "description": "Max 500." }, + "after": { "type": "string", "description": "Cursor." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/subscriptions", + "queryParams": { + "customer": "$customer", + "status": "$status", + "limit": "$limit", + "after": "$after" + } + } + }, + { + "name": "gocardless_create_subscription", + "description": "Create a subscription. `interval_unit` is weekly/monthly/yearly.", + "parameters": { + "type": "object", + "properties": { + "amount": { "type": "integer", "description": "Amount in minor units." }, + "currency": { "type": "string", "description": "ISO 4217." }, + "name": { "type": "string", "description": "Subscription name." }, + "interval_unit": { "type": "string", "description": "weekly, monthly, yearly." }, + "interval": { "type": "integer", "description": "Default 1." }, + "day_of_month": { "type": "integer", "description": "1-28 or -1=last day. Monthly only." }, + "mandate_id": { "type": "string", "description": "Mandate to charge." }, + "start_date": { "type": "string", "description": "YYYY-MM-DD." }, + "count": { "type": "integer", "description": "Total number of payments (omit for indefinite)." } + }, + "required": ["amount", "currency", "interval_unit", "mandate_id"] + }, + "endpointMapping": { + "method": "POST", + "path": "/subscriptions", + "bodyMapping": { + "subscriptions": { + "amount": "$amount", + "currency": "$currency", + "name": "$name", + "interval_unit": "$interval_unit", + "interval": "$interval", + "day_of_month": "$day_of_month", + "links": { "mandate": "$mandate_id" }, + "start_date": "$start_date", + "count": "$count" + } + } + } + }, + { + "name": "gocardless_list_payouts", + "description": "List payouts (settlement to your bank account).", + "parameters": { + "type": "object", + "properties": { + "status": { "type": "string", "description": "pending, paid, bounced." }, + "limit": { "type": "integer", "description": "Max 500." }, + "after": { "type": "string", "description": "Cursor." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/payouts", + "queryParams": { + "status": "$status", + "limit": "$limit", + "after": "$after" + } + } + }, + { + "name": "gocardless_create_refund", + "description": "Refund a payment (full or partial).", + "parameters": { + "type": "object", + "properties": { + "amount": { "type": "integer", "description": "Refund amount in minor units." }, + "payment_id": { "type": "string", "description": "Payment ID to refund." }, + "total_amount_confirmation": { "type": "integer", "description": "Total amount of the original payment — defensive guard." }, + "reference": { "type": "string", "description": "Reference for your records." } + }, + "required": ["amount", "payment_id", "total_amount_confirmation"] + }, + "endpointMapping": { + "method": "POST", + "path": "/refunds", + "bodyMapping": { + "refunds": { + "amount": "$amount", + "links": { "payment": "$payment_id" }, + "total_amount_confirmation": "$total_amount_confirmation", + "reference": "$reference" + } + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/gocardless.live.spec.ts b/packages/backend/src/adapters/intl/gocardless.live.spec.ts new file mode 100644 index 0000000..e1df82b --- /dev/null +++ b/packages/backend/src/adapters/intl/gocardless.live.spec.ts @@ -0,0 +1,12 @@ +import * as adapter from './gocardless.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; headers: Record }; +}; +describe('gocardless adapter — static spec conformance', () => { + it('api.gocardless.com base URL (live default)', () => + expect(a.connector.baseUrl).toBe('https://api.gocardless.com')); + it('Bearer auth + GoCardless-Version header', () => { + expect(a.connector.authType).toBe('BEARER_TOKEN'); + expect(a.connector.headers['GoCardless-Version']).toBe('2015-07-06'); + }); +}); diff --git a/packages/backend/src/adapters/intl/greenhouse.json b/packages/backend/src/adapters/intl/greenhouse.json new file mode 100644 index 0000000..908bb2b --- /dev/null +++ b/packages/backend/src/adapters/intl/greenhouse.json @@ -0,0 +1,224 @@ +{ + "slug": "greenhouse", + "name": "Greenhouse", + "description": "Manage Greenhouse (ATS recruiting: candidates, applications, jobs, scorecards, offers) from any AI agent. 10 tools, basic-auth.", + "instructions": "This connector wraps the Greenhouse Harvest API v1 (harvest.greenhouse.io/v1).\n\n**Setup**:\n1. In Greenhouse → **⚙️ Configure → Dev Center → API Credential Management → Create New API Key**.\n2. Type: **Harvest**. Permissions: pick what you need (read candidates/applications/jobs minimum; write for create endpoints).\n3. Set `GREENHOUSE_API_KEY`.\n\n**Authentication**: HTTP Basic — API key as username, empty password.\n\n**On-Behalf-Of header**: write endpoints REQUIRE `On-Behalf-Of: ` to attribute the action to a specific Greenhouse user. Pass `on_behalf_of` parameter when writing.\n\n**Candidate model**: a `Candidate` is the person; an `Application` is their candidacy to a specific Job. One candidate can have many applications. Scorecards, interviews, and offers all hang off Application.\n\n**Pagination**: `per_page` (max 500) + `page` (1-based). Total pages in `Link` header.\n\n**Rate limits**: 50 req/10s per API key. 429 with `Retry-After`.\n\n**Out of scope here**: candidate-survey responses, EEOC fields beyond list, custom-org chart navigation, the separate Partner / Job Board / Onboarding APIs (each have their own keys).", + "region": "intl", + "category": "hr", + "icon": "greenhouse", + "docsUrl": "https://developers.greenhouse.io/harvest.html", + "requiredEnvVars": ["GREENHOUSE_API_KEY"], + "connector": { + "name": "Greenhouse Harvest API v1", + "type": "REST", + "baseUrl": "https://harvest.greenhouse.io/v1", + "authType": "BASIC_AUTH", + "authConfig": { + "username": "{{GREENHOUSE_API_KEY}}", + "password": "" + } + }, + "tools": [ + { + "name": "greenhouse_list_candidates", + "description": "List candidates. Filter by created/updated date, job, status.", + "parameters": { + "type": "object", + "properties": { + "per_page": { "type": "integer", "description": "Max 500." }, + "page": { "type": "integer", "description": "1-based." }, + "created_after": { "type": "string", "description": "ISO 8601 datetime." }, + "updated_after": { "type": "string", "description": "ISO 8601." }, + "job_id": { "type": "integer", "description": "Filter to candidates with an application on this job." }, + "email": { "type": "string", "description": "Filter by primary email." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/candidates", + "queryParams": { + "per_page": "$per_page", + "page": "$page", + "created_after": "$created_after", + "updated_after": "$updated_after", + "job_id": "$job_id", + "email": "$email" + } + } + }, + { + "name": "greenhouse_get_candidate", + "description": "Get one candidate with all applications + custom fields.", + "parameters": { + "type": "object", + "properties": { + "candidate_id": { "type": "integer", "description": "Candidate ID." } + }, + "required": ["candidate_id"] + }, + "endpointMapping": { "method": "GET", "path": "/candidates/{candidate_id}" } + }, + { + "name": "greenhouse_create_candidate", + "description": "Create a candidate (+ optionally an application).", + "parameters": { + "type": "object", + "properties": { + "first_name": { "type": "string", "description": "First name." }, + "last_name": { "type": "string", "description": "Last name." }, + "email_addresses": { "type": "array", "description": "Array of {value, type:'personal'|'work'}." }, + "phone_numbers": { "type": "array", "description": "Array of {value, type}." }, + "applications": { "type": "array", "description": "Array of {job_id, source_id?, initial_stage_id?}." }, + "on_behalf_of": { "type": "integer", "description": "Greenhouse user ID to attribute the create to." } + }, + "required": ["first_name", "last_name", "on_behalf_of"] + }, + "endpointMapping": { + "method": "POST", + "path": "/candidates", + "headers": { "On-Behalf-Of": "${on_behalf_of}" }, + "bodyMapping": { + "first_name": "$first_name", + "last_name": "$last_name", + "email_addresses": "$email_addresses", + "phone_numbers": "$phone_numbers", + "applications": "$applications" + } + } + }, + { + "name": "greenhouse_list_applications", + "description": "List applications. Filter by candidate, job, status (active, hired, rejected).", + "parameters": { + "type": "object", + "properties": { + "candidate_id": { "type": "integer", "description": "Filter to one candidate." }, + "job_id": { "type": "integer", "description": "Filter to one job." }, + "status": { "type": "string", "description": "active, rejected, hired, converted." }, + "per_page": { "type": "integer", "description": "Max 500." }, + "page": { "type": "integer", "description": "1-based." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/applications", + "queryParams": { + "candidate_id": "$candidate_id", + "job_id": "$job_id", + "status": "$status", + "per_page": "$per_page", + "page": "$page" + } + } + }, + { + "name": "greenhouse_advance_application", + "description": "Move an application to the next pipeline stage. Optionally specify `to_stage_id` to skip stages.", + "parameters": { + "type": "object", + "properties": { + "application_id": { "type": "integer", "description": "Application ID." }, + "on_behalf_of": { "type": "integer", "description": "Greenhouse user ID." }, + "from_stage_id": { "type": "integer", "description": "Current stage (defensive concurrency check)." }, + "to_stage_id": { "type": "integer", "description": "Target stage. Omit to advance one." } + }, + "required": ["application_id", "on_behalf_of"] + }, + "endpointMapping": { + "method": "POST", + "path": "/applications/{application_id}/advance", + "headers": { "On-Behalf-Of": "${on_behalf_of}" }, + "bodyMapping": { "from_stage_id": "$from_stage_id", "to_stage_id": "$to_stage_id" } + } + }, + { + "name": "greenhouse_reject_application", + "description": "Reject an application with a reason.", + "parameters": { + "type": "object", + "properties": { + "application_id": { "type": "integer", "description": "Application ID." }, + "on_behalf_of": { "type": "integer", "description": "Greenhouse user ID." }, + "rejection_reason_id": { "type": "integer", "description": "Rejection reason ID." }, + "notes": { "type": "string", "description": "Optional notes visible to recruiters." } + }, + "required": ["application_id", "on_behalf_of", "rejection_reason_id"] + }, + "endpointMapping": { + "method": "POST", + "path": "/applications/{application_id}/reject", + "headers": { "On-Behalf-Of": "${on_behalf_of}" }, + "bodyMapping": { "rejection_reason_id": "$rejection_reason_id", "notes": "$notes" } + } + }, + { + "name": "greenhouse_list_jobs", + "description": "List jobs. Filter by status (open, closed, draft), department.", + "parameters": { + "type": "object", + "properties": { + "status": { "type": "string", "description": "open, closed, draft." }, + "department_id": { "type": "integer", "description": "Filter by department." }, + "per_page": { "type": "integer", "description": "Max 500." }, + "page": { "type": "integer", "description": "1-based." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/jobs", + "queryParams": { + "status": "$status", + "department_id": "$department_id", + "per_page": "$per_page", + "page": "$page" + } + } + }, + { + "name": "greenhouse_get_job", + "description": "Get one job with full job_post + hiring team + openings.", + "parameters": { + "type": "object", + "properties": { + "job_id": { "type": "integer", "description": "Job ID." } + }, + "required": ["job_id"] + }, + "endpointMapping": { "method": "GET", "path": "/jobs/{job_id}" } + }, + { + "name": "greenhouse_list_scorecards", + "description": "List scorecards for an application.", + "parameters": { + "type": "object", + "properties": { + "application_id": { "type": "integer", "description": "Application ID." }, + "per_page": { "type": "integer", "description": "Max 500." } + }, + "required": ["application_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/applications/{application_id}/scorecards", + "queryParams": { "per_page": "$per_page" } + } + }, + { + "name": "greenhouse_list_users", + "description": "List Greenhouse users (recruiters, interviewers, etc.).", + "parameters": { + "type": "object", + "properties": { + "per_page": { "type": "integer", "description": "Max 500." }, + "page": { "type": "integer", "description": "1-based." }, + "email": { "type": "string", "description": "Filter by email." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/users", + "queryParams": { "per_page": "$per_page", "page": "$page", "email": "$email" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/greenhouse.live.spec.ts b/packages/backend/src/adapters/intl/greenhouse.live.spec.ts new file mode 100644 index 0000000..9f5aee9 --- /dev/null +++ b/packages/backend/src/adapters/intl/greenhouse.live.spec.ts @@ -0,0 +1,8 @@ +import * as adapter from './greenhouse.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string } }; +describe('greenhouse adapter — static spec conformance', () => { + it('harvest.greenhouse.io/v1 base URL', () => + expect(a.connector.baseUrl).toBe('https://harvest.greenhouse.io/v1')); + it('Basic auth (API key as username)', () => + expect(a.connector.authType).toBe('BASIC_AUTH')); +}); diff --git a/packages/backend/src/adapters/intl/invoiced.json b/packages/backend/src/adapters/intl/invoiced.json new file mode 100644 index 0000000..cbe835e --- /dev/null +++ b/packages/backend/src/adapters/intl/invoiced.json @@ -0,0 +1,235 @@ +{ + "slug": "invoiced", + "name": "Invoiced", + "description": "Manage Invoiced (A/R automation: customers, invoices, payments, subscriptions, credit notes) from any AI agent. 9 tools, basic-auth.", + "instructions": "This connector wraps the Invoiced REST API (api.invoiced.com).\n\n**Setup**:\n1. Log into https://app.invoiced.com → **Developers → API Keys → Create API Key**.\n2. Set `INVOICED_API_KEY`.\n3. **Sandbox**: for testing, point baseUrl at `https://api.sandbox.invoiced.com` instead.\n\n**Authentication**: HTTP Basic — API key as username, empty password.\n\n**Hierarchy**: `Customer → Invoice → Line items → Payments`. Subscriptions generate recurring invoices automatically.\n\n**Pagination**: cursor-based via `Link` headers. Use `per_page` (max 100) and `page` (1-based).\n\n**Rate limits**: 60 req/min per API key, 1k/hr. 429 with `Retry-After`.\n\n**Out of scope here**: chasing workflow editor, payment-processor setup, dunning emails template management.", + "region": "intl", + "category": "accounting", + "icon": "invoiced", + "docsUrl": "https://invoiced.com/api", + "requiredEnvVars": ["INVOICED_API_KEY"], + "connector": { + "name": "Invoiced API", + "type": "REST", + "baseUrl": "https://api.invoiced.com", + "authType": "BASIC_AUTH", + "authConfig": { + "username": "{{INVOICED_API_KEY}}", + "password": "" + } + }, + "tools": [ + { + "name": "invoiced_list_customers", + "description": "List customers.", + "parameters": { + "type": "object", + "properties": { + "filter_name": { "type": "string", "description": "Name filter." }, + "page": { "type": "integer", "description": "1-based." }, + "per_page": { "type": "integer", "description": "Max 100." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/customers", + "queryParams": { + "filter[name]": "$filter_name", + "page": "$page", + "per_page": "$per_page" + } + } + }, + { + "name": "invoiced_get_customer", + "description": "Get one customer.", + "parameters": { + "type": "object", + "properties": { + "id": { "type": "integer", "description": "Customer ID." } + }, + "required": ["id"] + }, + "endpointMapping": { "method": "GET", "path": "/customers/{id}" } + }, + { + "name": "invoiced_create_customer", + "description": "Create a customer.", + "parameters": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Customer or company name." }, + "email": { "type": "string", "description": "Email." }, + "type": { "type": "string", "description": "'company' or 'person'." }, + "phone": { "type": "string", "description": "Phone." }, + "billing_address1": { "type": "string", "description": "Billing street." }, + "billing_city": { "type": "string", "description": "Billing city." }, + "billing_postal_code": { "type": "string", "description": "Billing ZIP." }, + "billing_country": { "type": "string", "description": "ISO 3166-1 alpha-2." }, + "payment_terms": { "type": "string", "description": "e.g. 'NET 30'." }, + "language": { "type": "string", "description": "ISO 639-1." } + }, + "required": ["name"] + }, + "endpointMapping": { + "method": "POST", + "path": "/customers", + "bodyMapping": { + "name": "$name", + "email": "$email", + "type": "$type", + "phone": "$phone", + "billing_address1": "$billing_address1", + "billing_city": "$billing_city", + "billing_postal_code": "$billing_postal_code", + "billing_country": "$billing_country", + "payment_terms": "$payment_terms", + "language": "$language" + } + } + }, + { + "name": "invoiced_list_invoices", + "description": "List invoices. Filter by customer, status (draft, sent, paid, voided), date range.", + "parameters": { + "type": "object", + "properties": { + "customer": { "type": "integer", "description": "Customer ID filter." }, + "status": { "type": "string", "description": "draft, not_sent, sent, paid, voided." }, + "date_from": { "type": "integer", "description": "UNIX epoch (seconds)." }, + "date_to": { "type": "integer", "description": "UNIX epoch (seconds)." }, + "page": { "type": "integer", "description": "1-based." }, + "per_page": { "type": "integer", "description": "Max 100." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/invoices", + "queryParams": { + "customer": "$customer", + "status": "$status", + "date[start]": "$date_from", + "date[end]": "$date_to", + "page": "$page", + "per_page": "$per_page" + } + } + }, + { + "name": "invoiced_get_invoice", + "description": "Get one invoice with line items + payments.", + "parameters": { + "type": "object", + "properties": { + "id": { "type": "integer", "description": "Invoice ID." } + }, + "required": ["id"] + }, + "endpointMapping": { "method": "GET", "path": "/invoices/{id}" } + }, + { + "name": "invoiced_create_invoice", + "description": "Create an invoice. `items` is an array of {name, quantity, unit_cost, description}.", + "parameters": { + "type": "object", + "properties": { + "customer": { "type": "integer", "description": "Customer ID." }, + "items": { "type": "array", "description": "Line items." }, + "date": { "type": "integer", "description": "Invoice date UNIX epoch." }, + "due_date": { "type": "integer", "description": "Due UNIX epoch." }, + "currency": { "type": "string", "description": "ISO 4217 (lowercase)." }, + "notes": { "type": "string", "description": "Free-text notes." }, + "payment_terms": { "type": "string", "description": "e.g. 'NET 30'." }, + "draft": { "type": "boolean", "description": "If true, create as draft." } + }, + "required": ["customer", "items"] + }, + "endpointMapping": { + "method": "POST", + "path": "/invoices", + "bodyMapping": { + "customer": "$customer", + "items": "$items", + "date": "$date", + "due_date": "$due_date", + "currency": "$currency", + "notes": "$notes", + "payment_terms": "$payment_terms", + "draft": "$draft" + } + } + }, + { + "name": "invoiced_send_invoice", + "description": "Send an invoice via email to its customer.", + "parameters": { + "type": "object", + "properties": { + "id": { "type": "integer", "description": "Invoice ID." }, + "to": { "type": "array", "description": "Array of {name, address}." }, + "subject": { "type": "string", "description": "Subject." }, + "message": { "type": "string", "description": "Body." } + }, + "required": ["id"] + }, + "endpointMapping": { + "method": "POST", + "path": "/invoices/{id}/emails", + "bodyMapping": { "to": "$to", "subject": "$subject", "message": "$message" } + } + }, + { + "name": "invoiced_list_payments", + "description": "List payments.", + "parameters": { + "type": "object", + "properties": { + "customer": { "type": "integer", "description": "Customer ID filter." }, + "invoice": { "type": "integer", "description": "Invoice ID filter." }, + "page": { "type": "integer", "description": "1-based." }, + "per_page": { "type": "integer", "description": "Max 100." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/payments", + "queryParams": { + "customer": "$customer", + "invoice": "$invoice", + "page": "$page", + "per_page": "$per_page" + } + } + }, + { + "name": "invoiced_create_payment", + "description": "Record a payment (off-system) and optionally apply it to invoices.", + "parameters": { + "type": "object", + "properties": { + "customer": { "type": "integer", "description": "Customer ID." }, + "amount": { "type": "number", "description": "Payment amount." }, + "currency": { "type": "string", "description": "ISO 4217 lowercase." }, + "method": { "type": "string", "description": "ach, cash, check, credit_card, wire_transfer, ..." }, + "date": { "type": "integer", "description": "Date UNIX epoch." }, + "reference": { "type": "string", "description": "Reference / check number." }, + "applied_to": { "type": "array", "description": "Array of {type:'invoice', invoice: , amount: } entries." } + }, + "required": ["customer", "amount"] + }, + "endpointMapping": { + "method": "POST", + "path": "/payments", + "bodyMapping": { + "customer": "$customer", + "amount": "$amount", + "currency": "$currency", + "method": "$method", + "date": "$date", + "reference": "$reference", + "applied_to": "$applied_to" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/invoiced.live.spec.ts b/packages/backend/src/adapters/intl/invoiced.live.spec.ts new file mode 100644 index 0000000..ce235ba --- /dev/null +++ b/packages/backend/src/adapters/intl/invoiced.live.spec.ts @@ -0,0 +1,8 @@ +import * as adapter from './invoiced.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string } }; +describe('invoiced adapter — static spec conformance', () => { + it('api.invoiced.com base URL', () => + expect(a.connector.baseUrl).toBe('https://api.invoiced.com')); + it('Basic auth (API key as username)', () => + expect(a.connector.authType).toBe('BASIC_AUTH')); +}); diff --git a/packages/backend/src/adapters/intl/kashflow.json b/packages/backend/src/adapters/intl/kashflow.json new file mode 100644 index 0000000..3fefc95 --- /dev/null +++ b/packages/backend/src/adapters/intl/kashflow.json @@ -0,0 +1,152 @@ +{ + "slug": "kashflow", + "name": "KashFlow", + "description": "Manage KashFlow (UK accounting) customers, suppliers, invoices, receipts, products via SOAP envelope from any AI agent. 8 tools, embedded-auth.", + "instructions": "This connector wraps the KashFlow REST API (securedwebapp.com/api).\n\n**Setup**:\n1. Log into https://securedwebapp.com → **Settings → API → Enable API access**.\n2. Note your Username + API password (separate from your login password).\n3. Set `KASHFLOW_USERNAME` and `KASHFLOW_PASSWORD`.\n\n**Authentication**: KashFlow uses a REST API with username + password in the URL path: `/api/v2/{username}/{password}/`. The adapter substitutes both via runtime envVars — the credentials never appear in query string or body.\n\n**API surface**: KashFlow exposes only a slim subset of the SOAP API as REST: customers, suppliers, invoices (CRUD), receipts, products. For features missing here (nominal codes, journals, projects) you'd need to call the SOAP endpoint directly — out of scope.\n\n**Pagination**: most list endpoints return a max of 100; pass `from` / `to` IDs to page.\n\n**Rate limits**: 60 req/min per account. 429 → back off 1s.\n\n**Out of scope here**: payroll, CIS, mileage logs, batch journals.", + "region": "intl", + "category": "accounting", + "icon": "kashflow", + "docsUrl": "https://www.kashflow.com/developers/", + "requiredEnvVars": ["KASHFLOW_USERNAME", "KASHFLOW_PASSWORD"], + "connector": { + "name": "KashFlow REST API v2", + "type": "REST", + "baseUrl": "https://securedwebapp.com/api/v2", + "authType": "NONE", + "authConfig": {} + }, + "tools": [ + { + "name": "kashflow_list_customers", + "description": "List customers.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { + "method": "GET", + "path": "/{KASHFLOW_USERNAME}/{KASHFLOW_PASSWORD}/customers" + } + }, + { + "name": "kashflow_get_customer", + "description": "Get one customer by KashFlow CustomerID.", + "parameters": { + "type": "object", + "properties": { + "customer_id": { "type": "integer", "description": "CustomerID." } + }, + "required": ["customer_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/{KASHFLOW_USERNAME}/{KASHFLOW_PASSWORD}/customers/{customer_id}" + } + }, + { + "name": "kashflow_create_customer", + "description": "Create a customer.", + "parameters": { + "type": "object", + "properties": { + "Name": { "type": "string", "description": "Display name." }, + "Code": { "type": "string", "description": "Internal code." }, + "Email": { "type": "string", "description": "Email." }, + "Telephone": { "type": "string", "description": "Phone." }, + "Address1": { "type": "string", "description": "Address line 1." }, + "Address2": { "type": "string", "description": "Address line 2." }, + "Address3": { "type": "string", "description": "Town." }, + "Address4": { "type": "string", "description": "County." }, + "Postcode": { "type": "string", "description": "Postcode." }, + "CountryCode": { "type": "string", "description": "ISO 3166-1 alpha-2." } + }, + "required": ["Name"] + }, + "endpointMapping": { + "method": "POST", + "path": "/{KASHFLOW_USERNAME}/{KASHFLOW_PASSWORD}/customers", + "bodyMapping": { + "Name": "$Name", + "Code": "$Code", + "Email": "$Email", + "Telephone": "$Telephone", + "Address1": "$Address1", + "Address2": "$Address2", + "Address3": "$Address3", + "Address4": "$Address4", + "Postcode": "$Postcode", + "CountryCode": "$CountryCode" + } + } + }, + { + "name": "kashflow_list_suppliers", + "description": "List suppliers.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { + "method": "GET", + "path": "/{KASHFLOW_USERNAME}/{KASHFLOW_PASSWORD}/suppliers" + } + }, + { + "name": "kashflow_list_invoices", + "description": "List sales invoices.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { + "method": "GET", + "path": "/{KASHFLOW_USERNAME}/{KASHFLOW_PASSWORD}/invoices" + } + }, + { + "name": "kashflow_get_invoice", + "description": "Get a single invoice with line items.", + "parameters": { + "type": "object", + "properties": { + "invoice_id": { "type": "integer", "description": "InvoiceID." } + }, + "required": ["invoice_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/{KASHFLOW_USERNAME}/{KASHFLOW_PASSWORD}/invoices/{invoice_id}" + } + }, + { + "name": "kashflow_create_invoice", + "description": "Create a sales invoice. `Lines` is an array of {Quantity, Description, Rate, ChargeType, VatRate, VatAmount, ProductID}.", + "parameters": { + "type": "object", + "properties": { + "CustomerID": { "type": "integer", "description": "Customer ID." }, + "InvoiceDate": { "type": "string", "description": "ISO 8601 date." }, + "DueDate": { "type": "string", "description": "ISO 8601 date." }, + "InvoiceNumber": { "type": "integer", "description": "Invoice number (set 0 to auto-allocate)." }, + "Lines": { "type": "array", "description": "Line items." }, + "CustomerReference": { "type": "string", "description": "Customer PO ref." }, + "ProjectID": { "type": "integer", "description": "Project ID (optional)." } + }, + "required": ["CustomerID", "InvoiceDate", "Lines"] + }, + "endpointMapping": { + "method": "POST", + "path": "/{KASHFLOW_USERNAME}/{KASHFLOW_PASSWORD}/invoices", + "bodyMapping": { + "CustomerID": "$CustomerID", + "InvoiceDate": "$InvoiceDate", + "DueDate": "$DueDate", + "InvoiceNumber": "$InvoiceNumber", + "Lines": "$Lines", + "CustomerReference": "$CustomerReference", + "ProjectID": "$ProjectID" + } + } + }, + { + "name": "kashflow_list_products", + "description": "List products / services.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { + "method": "GET", + "path": "/{KASHFLOW_USERNAME}/{KASHFLOW_PASSWORD}/products" + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/kashflow.live.spec.ts b/packages/backend/src/adapters/intl/kashflow.live.spec.ts new file mode 100644 index 0000000..1a0e6c7 --- /dev/null +++ b/packages/backend/src/adapters/intl/kashflow.live.spec.ts @@ -0,0 +1,15 @@ +import * as adapter from './kashflow.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string }; + tools: Array<{ endpointMapping: { path: string } }>; +}; +describe('kashflow adapter — static spec conformance', () => { + it('securedwebapp.com/api/v2 base URL', () => + expect(a.connector.baseUrl).toBe('https://securedwebapp.com/api/v2')); + it('every tool embeds username + password in the path', () => { + for (const t of a.tools) { + expect(t.endpointMapping.path).toContain('{KASHFLOW_USERNAME}'); + expect(t.endpointMapping.path).toContain('{KASHFLOW_PASSWORD}'); + } + }); +}); diff --git a/packages/backend/src/adapters/intl/less-annoying-crm.json b/packages/backend/src/adapters/intl/less-annoying-crm.json new file mode 100644 index 0000000..add57d9 --- /dev/null +++ b/packages/backend/src/adapters/intl/less-annoying-crm.json @@ -0,0 +1,231 @@ +{ + "slug": "less-annoying-crm", + "name": "Less Annoying CRM", + "description": "Manage Less Annoying CRM (contacts, companies, notes, tasks, pipelines) from any AI agent. 8 tools, dual-header API auth.", + "instructions": "This connector wraps the Less Annoying CRM API (api.lessannoyingcrm.com).\n\n**Setup**:\n1. Sign in to https://www.lessannoyingcrm.com → top-right avatar → **My Account → API → Create new API key**.\n2. Note both the `UserCode` (your LACRM user code) and the `API Token`.\n3. Set `LACRM_USER_CODE` and `LACRM_API_TOKEN`.\n\n**Authentication**: LACRM uses a POST-only API — every call is `POST /` with `UserCode`, `APIToken`, `Function` and `Parameters` (JSON-stringified) as form fields. The adapter wraps this so each tool exposes a normal interface and hides the unusual envelope.\n\n**Function dispatch**: every LACRM operation is a single `Function` name (`GetContact`, `CreateContact`, ...). Parameters go inside the `Parameters` form field as JSON.\n\n**Custom fields**: are returned/posted by their numeric `FieldId`. Use `GetCustomFields` to discover IDs first.\n\n**Pagination**: `Page` (1-based) on list functions; results are 25 per page (fixed).\n\n**Rate limits**: 300 req/min per API token. 429 with `Retry-After`.\n\n**Out of scope here**: workflow automation (LACRM is intentionally minimal), email tracking, calendar sync.", + "region": "intl", + "category": "crm", + "icon": "less-annoying-crm", + "docsUrl": "https://www.lessannoyingcrm.com/help/category/63/14/api", + "requiredEnvVars": ["LACRM_USER_CODE", "LACRM_API_TOKEN"], + "connector": { + "name": "Less Annoying CRM API", + "type": "REST", + "baseUrl": "https://api.lessannoyingcrm.com", + "authType": "NONE", + "authConfig": {} + }, + "tools": [ + { + "name": "lacrm_search_contacts", + "description": "Search contacts by name, email, phone, etc.", + "parameters": { + "type": "object", + "properties": { + "search_term": { "type": "string", "description": "Free-text search." }, + "page": { "type": "integer", "description": "1-based page." } + }, + "required": ["search_term"] + }, + "endpointMapping": { + "method": "POST", + "path": "/", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { + "UserCode": "$LACRM_USER_CODE", + "APIToken": "$LACRM_API_TOKEN", + "Function": "SearchContacts", + "Parameters": { "SearchTerm": "$search_term", "Page": "$page" } + } + } + }, + { + "name": "lacrm_get_contact", + "description": "Get a single contact by LACRM Contact ID.", + "parameters": { + "type": "object", + "properties": { + "contact_id": { "type": "string", "description": "Contact ID." } + }, + "required": ["contact_id"] + }, + "endpointMapping": { + "method": "POST", + "path": "/", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { + "UserCode": "$LACRM_USER_CODE", + "APIToken": "$LACRM_API_TOKEN", + "Function": "GetContact", + "Parameters": { "ContactId": "$contact_id" } + } + } + }, + { + "name": "lacrm_create_contact", + "description": "Create a contact.", + "parameters": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Full name." }, + "company_name": { "type": "string", "description": "Company name." }, + "email": { "type": "array", "description": "Array of {Text, Type:'Work'|'Personal'}." }, + "phone": { "type": "array", "description": "Array of {Text, Type}." }, + "address": { "type": "array", "description": "Array of {Street, City, State, Zip, Country, Type}." }, + "background_info": { "type": "string", "description": "Free-text background." }, + "custom_fields": { "type": "object", "description": "Map of CustomFieldId → value." } + }, + "required": ["name"] + }, + "endpointMapping": { + "method": "POST", + "path": "/", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { + "UserCode": "$LACRM_USER_CODE", + "APIToken": "$LACRM_API_TOKEN", + "Function": "CreateContact", + "Parameters": { + "Name": "$name", + "CompanyName": "$company_name", + "Email": "$email", + "Phone": "$phone", + "Address": "$address", + "BackgroundInfo": "$background_info", + "CustomFields": "$custom_fields" + } + } + } + }, + { + "name": "lacrm_update_contact", + "description": "Update a contact — pass ContactId + only the fields to change.", + "parameters": { + "type": "object", + "properties": { + "contact_id": { "type": "string", "description": "Contact ID." }, + "name": { "type": "string", "description": "New name." }, + "company_name": { "type": "string", "description": "New company." }, + "background_info": { "type": "string", "description": "New background." }, + "custom_fields": { "type": "object", "description": "CustomFieldId → value." } + }, + "required": ["contact_id"] + }, + "endpointMapping": { + "method": "POST", + "path": "/", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { + "UserCode": "$LACRM_USER_CODE", + "APIToken": "$LACRM_API_TOKEN", + "Function": "EditContact", + "Parameters": { + "ContactId": "$contact_id", + "Name": "$name", + "CompanyName": "$company_name", + "BackgroundInfo": "$background_info", + "CustomFields": "$custom_fields" + } + } + } + }, + { + "name": "lacrm_create_note", + "description": "Add a note to a contact.", + "parameters": { + "type": "object", + "properties": { + "contact_id": { "type": "string", "description": "Contact ID." }, + "note": { "type": "string", "description": "Note body." } + }, + "required": ["contact_id", "note"] + }, + "endpointMapping": { + "method": "POST", + "path": "/", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { + "UserCode": "$LACRM_USER_CODE", + "APIToken": "$LACRM_API_TOKEN", + "Function": "CreateNote", + "Parameters": { "ContactId": "$contact_id", "Note": "$note" } + } + } + }, + { + "name": "lacrm_create_task", + "description": "Create a task assigned to one or more users, optionally linked to a contact.", + "parameters": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Task description." }, + "due_date": { "type": "string", "description": "YYYY-MM-DD." }, + "assigned_to": { "type": "array", "description": "Array of LACRM user codes." }, + "contact_id": { "type": "string", "description": "Linked contact." }, + "priority": { "type": "string", "description": "High, Medium, Low." } + }, + "required": ["name"] + }, + "endpointMapping": { + "method": "POST", + "path": "/", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { + "UserCode": "$LACRM_USER_CODE", + "APIToken": "$LACRM_API_TOKEN", + "Function": "CreateTask", + "Parameters": { + "Name": "$name", + "DueDate": "$due_date", + "AssignedTo": "$assigned_to", + "ContactId": "$contact_id", + "Priority": "$priority" + } + } + } + }, + { + "name": "lacrm_get_pipeline_report", + "description": "Get the pipeline (opportunities) report.", + "parameters": { + "type": "object", + "properties": { + "pipeline_id": { "type": "string", "description": "Pipeline ID (optional — defaults to all)." }, + "sort_by": { "type": "string", "description": "Field name to sort by." }, + "num_rows": { "type": "integer", "description": "Max rows (default 25)." } + } + }, + "endpointMapping": { + "method": "POST", + "path": "/", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { + "UserCode": "$LACRM_USER_CODE", + "APIToken": "$LACRM_API_TOKEN", + "Function": "GetPipelineReport", + "Parameters": { + "PipelineId": "$pipeline_id", + "SortBy": "$sort_by", + "NumRows": "$num_rows" + } + } + } + }, + { + "name": "lacrm_get_custom_fields", + "description": "List all custom fields defined in the account — returns id, name, type. Call this first to discover CustomFieldId values you'll need for create/update.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { + "method": "POST", + "path": "/", + "bodyEncoding": "form-urlencoded", + "bodyMapping": { + "UserCode": "$LACRM_USER_CODE", + "APIToken": "$LACRM_API_TOKEN", + "Function": "GetCustomFields", + "Parameters": {} + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/less-annoying-crm.live.spec.ts b/packages/backend/src/adapters/intl/less-annoying-crm.live.spec.ts new file mode 100644 index 0000000..16f0ce5 --- /dev/null +++ b/packages/backend/src/adapters/intl/less-annoying-crm.live.spec.ts @@ -0,0 +1,15 @@ +import * as adapter from './less-annoying-crm.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string }; + tools: Array<{ endpointMapping: { method: string; path: string } }>; +}; +describe('less-annoying-crm adapter — static spec conformance', () => { + it('api.lessannoyingcrm.com base URL', () => + expect(a.connector.baseUrl).toBe('https://api.lessannoyingcrm.com')); + it('every tool POSTs to root (Function-dispatch envelope)', () => { + for (const t of a.tools) { + expect(t.endpointMapping.method).toBe('POST'); + expect(t.endpointMapping.path).toBe('/'); + } + }); +}); diff --git a/packages/backend/src/adapters/intl/lever.json b/packages/backend/src/adapters/intl/lever.json new file mode 100644 index 0000000..c784f74 --- /dev/null +++ b/packages/backend/src/adapters/intl/lever.json @@ -0,0 +1,213 @@ +{ + "slug": "lever", + "name": "Lever", + "description": "Manage Lever ATS (candidates, opportunities, postings, stages, feedback) from any AI agent. 10 tools, basic-auth.", + "instructions": "This connector wraps the Lever Hire API v1 (api.lever.co/v1).\n\n**Setup**:\n1. In Lever → **Settings → Integrations and API → API Credentials → Generate new key**.\n2. Set `LEVER_API_KEY`.\n3. **Sandbox**: use `https://api.sandbox.lever.co/v1` instead during testing.\n\n**Authentication**: HTTP Basic — API key as username, empty password.\n\n**Opportunity vs Candidate**: in Lever's modern API, an `Opportunity` represents a candidate's interest in a single posting. A person can have multiple opportunities (one per role). The legacy `Candidate` endpoint still works but Opportunity is preferred.\n\n**Pagination**: cursor-based. List endpoints return `next` (cursor) in the response body; pass back as `offset`.\n\n**Rate limits**: 8 req/sec per API key. 429 with `Retry-After`.\n\n**Out of scope here**: requisitions CRUD beyond list, EEO survey responses, the separate Nurture / Sourcer extension APIs.", + "region": "intl", + "category": "hr", + "icon": "lever", + "docsUrl": "https://hire.lever.co/developer/documentation", + "requiredEnvVars": ["LEVER_API_KEY"], + "connector": { + "name": "Lever Hire API v1", + "type": "REST", + "baseUrl": "https://api.lever.co/v1", + "authType": "BASIC_AUTH", + "authConfig": { + "username": "{{LEVER_API_KEY}}", + "password": "" + } + }, + "tools": [ + { + "name": "lever_list_opportunities", + "description": "List opportunities (candidate × posting). Filter by stage, posting, owner, created_at.", + "parameters": { + "type": "object", + "properties": { + "stage_id": { "type": "string", "description": "Filter by stage." }, + "posting_id": { "type": "string", "description": "Filter by posting." }, + "owner_id": { "type": "string", "description": "Filter by owner." }, + "archived": { "type": "boolean", "description": "Include archived." }, + "created_at_start": { "type": "integer", "description": "UNIX epoch ms." }, + "created_at_end": { "type": "integer", "description": "UNIX epoch ms." }, + "limit": { "type": "integer", "description": "Max per page." }, + "offset": { "type": "string", "description": "Cursor from previous response." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/opportunities", + "queryParams": { + "stage_id": "$stage_id", + "posting_id": "$posting_id", + "owner_id": "$owner_id", + "archived": "$archived", + "created_at_start": "$created_at_start", + "created_at_end": "$created_at_end", + "limit": "$limit", + "offset": "$offset" + } + } + }, + { + "name": "lever_get_opportunity", + "description": "Get one opportunity with full contact, links, tags, applications.", + "parameters": { + "type": "object", + "properties": { + "opportunity_id": { "type": "string", "description": "Opportunity ID." } + }, + "required": ["opportunity_id"] + }, + "endpointMapping": { "method": "GET", "path": "/opportunities/{opportunity_id}" } + }, + { + "name": "lever_create_opportunity", + "description": "Create an opportunity (candidate with associated posting). Required: name + at least one email; perform_as is the Lever user ID acting.", + "parameters": { + "type": "object", + "properties": { + "perform_as": { "type": "string", "description": "Lever user ID making the create." }, + "name": { "type": "string", "description": "Candidate name." }, + "emails": { "type": "array", "description": "Array of email strings." }, + "phones": { "type": "array", "description": "Array of {type, value}." }, + "location": { "type": "object", "description": "{name} location." }, + "postings": { "type": "array", "description": "Array of posting ID strings to apply to." }, + "stage": { "type": "string", "description": "Initial stage ID." }, + "owner": { "type": "string", "description": "Lever user ID owner." }, + "tags": { "type": "array", "description": "Tag strings." }, + "origin": { "type": "string", "description": "applied, referred, sourced." } + }, + "required": ["perform_as", "name", "emails"] + }, + "endpointMapping": { + "method": "POST", + "path": "/opportunities", + "queryParams": { "perform_as": "$perform_as" }, + "bodyMapping": { + "name": "$name", + "emails": "$emails", + "phones": "$phones", + "location": "$location", + "postings": "$postings", + "stage": "$stage", + "owner": "$owner", + "tags": "$tags", + "origin": "$origin" + } + } + }, + { + "name": "lever_update_opportunity_stage", + "description": "Move an opportunity to a different stage.", + "parameters": { + "type": "object", + "properties": { + "opportunity_id": { "type": "string", "description": "Opportunity ID." }, + "perform_as": { "type": "string", "description": "Lever user ID." }, + "stage": { "type": "string", "description": "Target stage ID." } + }, + "required": ["opportunity_id", "perform_as", "stage"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/opportunities/{opportunity_id}/stage", + "queryParams": { "perform_as": "$perform_as" }, + "bodyMapping": { "stage": "$stage" } + } + }, + { + "name": "lever_archive_opportunity", + "description": "Archive an opportunity with an archive reason.", + "parameters": { + "type": "object", + "properties": { + "opportunity_id": { "type": "string", "description": "Opportunity ID." }, + "perform_as": { "type": "string", "description": "Lever user ID." }, + "reason": { "type": "string", "description": "Archive reason ID." } + }, + "required": ["opportunity_id", "perform_as", "reason"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/opportunities/{opportunity_id}/archived", + "queryParams": { "perform_as": "$perform_as" }, + "bodyMapping": { "reason": "$reason" } + } + }, + { + "name": "lever_list_postings", + "description": "List job postings.", + "parameters": { + "type": "object", + "properties": { + "state": { "type": "string", "description": "published, internal, closed, draft, pending, rejected." }, + "team": { "type": "string", "description": "Filter by team." }, + "location": { "type": "string", "description": "Filter by location." }, + "limit": { "type": "integer", "description": "Max per page." }, + "offset": { "type": "string", "description": "Cursor." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/postings", + "queryParams": { + "state": "$state", + "team": "$team", + "location": "$location", + "limit": "$limit", + "offset": "$offset" + } + } + }, + { + "name": "lever_list_stages", + "description": "List pipeline stages.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/stages" } + }, + { + "name": "lever_list_users", + "description": "List Lever users (recruiters, hiring managers).", + "parameters": { + "type": "object", + "properties": { + "access_role": { "type": "string", "description": "Filter by role." }, + "email": { "type": "string", "description": "Filter by email." }, + "limit": { "type": "integer", "description": "Max per page." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/users", + "queryParams": { + "access_role": "$access_role", + "email": "$email", + "limit": "$limit" + } + } + }, + { + "name": "lever_list_archive_reasons", + "description": "List archive reasons configured in the org. Use this to find valid `reason` IDs for archive calls.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/archive_reasons" } + }, + { + "name": "lever_list_feedback", + "description": "List interview feedback forms attached to an opportunity.", + "parameters": { + "type": "object", + "properties": { + "opportunity_id": { "type": "string", "description": "Opportunity ID." } + }, + "required": ["opportunity_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/opportunities/{opportunity_id}/feedback" + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/lever.live.spec.ts b/packages/backend/src/adapters/intl/lever.live.spec.ts new file mode 100644 index 0000000..847fd46 --- /dev/null +++ b/packages/backend/src/adapters/intl/lever.live.spec.ts @@ -0,0 +1,8 @@ +import * as adapter from './lever.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string } }; +describe('lever adapter — static spec conformance', () => { + it('api.lever.co/v1 base URL', () => + expect(a.connector.baseUrl).toBe('https://api.lever.co/v1')); + it('Basic auth (API key as username, empty password)', () => + expect(a.connector.authType).toBe('BASIC_AUTH')); +}); diff --git a/packages/backend/src/adapters/intl/mailerlite.json b/packages/backend/src/adapters/intl/mailerlite.json new file mode 100644 index 0000000..bbd2aa9 --- /dev/null +++ b/packages/backend/src/adapters/intl/mailerlite.json @@ -0,0 +1,182 @@ +{ + "slug": "mailerlite", + "name": "MailerLite", + "description": "Manage MailerLite (email marketing: subscribers, groups, campaigns, automations, fields) from any AI agent. 10 tools, Bearer token.", + "instructions": "This connector wraps the MailerLite API v2 (connect.mailerlite.com/api).\n\n**Setup**:\n1. Log into https://dashboard.mailerlite.com → bottom-left avatar → **Integrations → MailerLite API → Generate new token**.\n2. Pick scopes (read/write subscribers, groups, campaigns, fields, automations).\n3. Set `MAILERLITE_API_TOKEN`.\n\n**Authentication**: `Authorization: Bearer ${MAILERLITE_API_TOKEN}` + `Accept: application/json`.\n\n**Subscriber lifecycle**: every email becomes a `Subscriber` with status (active, unsubscribed, junk, unconfirmed, bounced). Adding the same email twice updates rather than duplicating.\n\n**Groups**: tags for segmentation. A subscriber can belong to many groups.\n\n**Pagination**: `cursor` style — response includes `links.next` with cursor URL. Use `cursor` query param to page. `limit` (max 100).\n\n**Rate limits**: 120 req/min per token (Free/Growing); 240/min on Advanced. 429 with `Retry-After`.\n\n**Out of scope here**: landing pages, popups, segment SQL builder, e-commerce attribution.", + "region": "intl", + "category": "email", + "icon": "mailerlite", + "docsUrl": "https://developers.mailerlite.com/docs/", + "requiredEnvVars": ["MAILERLITE_API_TOKEN"], + "connector": { + "name": "MailerLite API", + "type": "REST", + "baseUrl": "https://connect.mailerlite.com/api", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{MAILERLITE_API_TOKEN}}" + } + }, + "tools": [ + { + "name": "mailerlite_list_subscribers", + "description": "List subscribers. Filter by status, group, search.", + "parameters": { + "type": "object", + "properties": { + "status": { "type": "string", "description": "active, unsubscribed, junk, unconfirmed, bounced." }, + "filter_search": { "type": "string", "description": "Email/name search." }, + "limit": { "type": "integer", "description": "Max 100." }, + "cursor": { "type": "string", "description": "Pagination cursor." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/subscribers", + "queryParams": { + "filter[status]": "$status", + "filter[search]": "$filter_search", + "limit": "$limit", + "cursor": "$cursor" + } + } + }, + { + "name": "mailerlite_get_subscriber", + "description": "Get one subscriber by ID or email.", + "parameters": { + "type": "object", + "properties": { + "subscriber_id_or_email": { "type": "string", "description": "Numeric ID OR email." } + }, + "required": ["subscriber_id_or_email"] + }, + "endpointMapping": { + "method": "GET", + "path": "/subscribers/{subscriber_id_or_email}" + } + }, + { + "name": "mailerlite_upsert_subscriber", + "description": "Create or update a subscriber (idempotent on email). `fields` is a flat map of field-name → value.", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "Email (required)." }, + "fields": { "type": "object", "description": "Custom field map (e.g. {name:'Jane', city:'Berlin'})." }, + "groups": { "type": "array", "description": "Array of group ID strings to add to." }, + "status": { "type": "string", "description": "active, unsubscribed, unconfirmed, ..." }, + "subscribed_at": { "type": "string", "description": "ISO 8601 datetime." }, + "ip_address": { "type": "string", "description": "Subscriber IP." } + }, + "required": ["email"] + }, + "endpointMapping": { + "method": "POST", + "path": "/subscribers", + "bodyMapping": { + "email": "$email", + "fields": "$fields", + "groups": "$groups", + "status": "$status", + "subscribed_at": "$subscribed_at", + "ip_address": "$ip_address" + } + } + }, + { + "name": "mailerlite_delete_subscriber", + "description": "Permanently delete a subscriber.", + "parameters": { + "type": "object", + "properties": { + "subscriber_id": { "type": "string", "description": "Subscriber ID." } + }, + "required": ["subscriber_id"] + }, + "endpointMapping": { "method": "DELETE", "path": "/subscribers/{subscriber_id}" } + }, + { + "name": "mailerlite_list_groups", + "description": "List groups.", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Max 100." }, + "filter_name": { "type": "string", "description": "Filter by name." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/groups", + "queryParams": { "limit": "$limit", "filter[name]": "$filter_name" } + } + }, + { + "name": "mailerlite_create_group", + "description": "Create a group.", + "parameters": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Group name." } + }, + "required": ["name"] + }, + "endpointMapping": { + "method": "POST", + "path": "/groups", + "bodyMapping": { "name": "$name" } + } + }, + { + "name": "mailerlite_assign_to_group", + "description": "Add a subscriber to a group.", + "parameters": { + "type": "object", + "properties": { + "subscriber_id": { "type": "string", "description": "Subscriber ID." }, + "group_id": { "type": "string", "description": "Group ID." } + }, + "required": ["subscriber_id", "group_id"] + }, + "endpointMapping": { + "method": "POST", + "path": "/subscribers/{subscriber_id}/groups/{group_id}" + } + }, + { + "name": "mailerlite_list_campaigns", + "description": "List campaigns. Filter by status (draft, sent, ready, queued).", + "parameters": { + "type": "object", + "properties": { + "status": { "type": "string", "description": "draft, sent, ready, queued, canceled." }, + "limit": { "type": "integer", "description": "Max 100." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/campaigns", + "queryParams": { "filter[status]": "$status", "limit": "$limit" } + } + }, + { + "name": "mailerlite_get_campaign", + "description": "Get one campaign with delivery stats.", + "parameters": { + "type": "object", + "properties": { + "campaign_id": { "type": "string", "description": "Campaign ID." } + }, + "required": ["campaign_id"] + }, + "endpointMapping": { "method": "GET", "path": "/campaigns/{campaign_id}" } + }, + { + "name": "mailerlite_list_fields", + "description": "List subscriber fields (built-in + custom). Use to discover what `fields` keys you can populate.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/fields" } + } + ] +} diff --git a/packages/backend/src/adapters/intl/mailerlite.live.spec.ts b/packages/backend/src/adapters/intl/mailerlite.live.spec.ts new file mode 100644 index 0000000..d34c192 --- /dev/null +++ b/packages/backend/src/adapters/intl/mailerlite.live.spec.ts @@ -0,0 +1,7 @@ +import * as adapter from './mailerlite.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string } }; +describe('mailerlite adapter — static spec conformance', () => { + it('connect.mailerlite.com/api base URL', () => + expect(a.connector.baseUrl).toBe('https://connect.mailerlite.com/api')); + it('Bearer auth', () => expect(a.connector.authType).toBe('BEARER_TOKEN')); +}); diff --git a/packages/backend/src/adapters/intl/nimble.json b/packages/backend/src/adapters/intl/nimble.json new file mode 100644 index 0000000..0737ae8 --- /dev/null +++ b/packages/backend/src/adapters/intl/nimble.json @@ -0,0 +1,195 @@ +{ + "slug": "nimble", + "name": "Nimble", + "description": "Manage Nimble CRM (contacts, activities, deals, tasks, notes) from any AI agent. 9 tools, OAuth2 auth.", + "instructions": "This connector wraps the Nimble REST API v1 (app.nimble.com/api/v1).\n\n**Setup — OAuth2 refresh flow**:\n1. Sign in to https://app.nimble.com → **Settings → Integrations → API → Create OAuth app**.\n2. Note the `client_id`, `client_secret`, and complete the OAuth dance at `https://app.nimble.com/oauth/authorize?response_type=code&client_id=...&redirect_uri=...&scope=...` to get an authorization code.\n3. Exchange for tokens: `POST https://app.nimble.com/oauth/token?grant_type=authorization_code&code=...&client_id=...&client_secret=...&redirect_uri=...`.\n4. Save the `refresh_token`. Set `NIMBLE_CLIENT_ID`, `NIMBLE_CLIENT_SECRET`, `NIMBLE_REFRESH_TOKEN`.\n\n**Authentication**: OAuth2 — engine handles the refresh. Access token sent as `Authorization: Bearer ACCESS_TOKEN`.\n\n**Contact model**: Nimble unifies leads + contacts + companies into a single 'Contact' object with `record_type` discriminator (`person` or `company`). Fields are namespaced — `fields['email'][0].value`, `fields['first name'][0].value` etc.\n\n**Pagination**: `page` (1-based) + `per_page` (max 30).\n\n**Rate limits**: 5 req/sec per token. 429 → exponential backoff.\n\n**Out of scope here**: pipeline analytics, social-listening streams, email-sync settings, group messaging.", + "region": "intl", + "category": "crm", + "icon": "nimble", + "docsUrl": "https://nimble.readme.io/", + "requiredEnvVars": ["NIMBLE_CLIENT_ID", "NIMBLE_CLIENT_SECRET", "NIMBLE_REFRESH_TOKEN"], + "connector": { + "name": "Nimble API v1", + "type": "REST", + "baseUrl": "https://app.nimble.com/api/v1", + "authType": "OAUTH2", + "authConfig": { + "clientId": "{{NIMBLE_CLIENT_ID}}", + "clientSecret": "{{NIMBLE_CLIENT_SECRET}}", + "refreshToken": "{{NIMBLE_REFRESH_TOKEN}}", + "tokenUrl": "https://app.nimble.com/oauth/token" + } + }, + "tools": [ + { + "name": "nimble_myself", + "description": "Return the authenticated user info.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/myself" } + }, + { + "name": "nimble_list_contacts", + "description": "List contacts (people + companies). Filter by record_type, query, tag.", + "parameters": { + "type": "object", + "properties": { + "record_type": { "type": "string", "description": "person, company, lead." }, + "query": { "type": "string", "description": "Free-text search." }, + "tag": { "type": "string", "description": "Filter by tag name." }, + "page": { "type": "integer", "description": "1-based." }, + "per_page": { "type": "integer", "description": "Max 30." }, + "sort": { "type": "string", "description": "Field name. Prefix with - for desc." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/contacts", + "queryParams": { + "record_type": "$record_type", + "query": "$query", + "tag": "$tag", + "page": "$page", + "per_page": "$per_page", + "sort": "$sort" + } + } + }, + { + "name": "nimble_get_contact", + "description": "Get one contact by ID with all fields, tags, social-network links.", + "parameters": { + "type": "object", + "properties": { + "id": { "type": "string", "description": "Contact ID." } + }, + "required": ["id"] + }, + "endpointMapping": { "method": "GET", "path": "/contact/{id}" } + }, + { + "name": "nimble_create_contact", + "description": "Create a contact. `fields` is the Nimble namespaced shape — `{\"first name\":[{value:'John'}], email:[{value:'a@b.com', modifier:'work'}]}`.", + "parameters": { + "type": "object", + "properties": { + "record_type": { "type": "string", "description": "person or company." }, + "fields": { "type": "object", "description": "Field-name → array-of-{value, modifier?} map." }, + "tags": { "type": "array", "description": "Array of tag names." } + }, + "required": ["record_type", "fields"] + }, + "endpointMapping": { + "method": "POST", + "path": "/contact", + "bodyMapping": { + "record_type": "$record_type", + "fields": "$fields", + "tags": "$tags" + } + } + }, + { + "name": "nimble_update_contact", + "description": "Update a contact (PATCH semantics — supply only fields you want changed).", + "parameters": { + "type": "object", + "properties": { + "id": { "type": "string", "description": "Contact ID." }, + "fields": { "type": "object", "description": "Fields to update." } + }, + "required": ["id", "fields"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/contact/{id}", + "bodyMapping": { "fields": "$fields" } + } + }, + { + "name": "nimble_delete_contact", + "description": "Permanently delete a contact.", + "parameters": { + "type": "object", + "properties": { + "id": { "type": "string", "description": "Contact ID." } + }, + "required": ["id"] + }, + "endpointMapping": { "method": "DELETE", "path": "/contact/{id}" } + }, + { + "name": "nimble_list_activities", + "description": "List activities (calls, meetings, tasks, emails) for the workspace.", + "parameters": { + "type": "object", + "properties": { + "contact_id": { "type": "string", "description": "Filter to one contact." }, + "type": { "type": "string", "description": "task, event, phone_call, email." }, + "page": { "type": "integer", "description": "1-based." }, + "per_page": { "type": "integer", "description": "Max 30." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/activities", + "queryParams": { + "contact_id": "$contact_id", + "type": "$type", + "page": "$page", + "per_page": "$per_page" + } + } + }, + { + "name": "nimble_create_activity", + "description": "Create an activity (task, call note, meeting log).", + "parameters": { + "type": "object", + "properties": { + "type": { "type": "string", "description": "task, event, phone_call, email." }, + "subject": { "type": "string", "description": "Subject / title." }, + "description": { "type": "string", "description": "Body." }, + "contact_id": { "type": "string", "description": "Linked contact." }, + "start_time": { "type": "string", "description": "ISO 8601 start." }, + "end_time": { "type": "string", "description": "ISO 8601 end." } + }, + "required": ["type", "subject"] + }, + "endpointMapping": { + "method": "POST", + "path": "/activity", + "bodyMapping": { + "type": "$type", + "subject": "$subject", + "description": "$description", + "contact_id": "$contact_id", + "start_time": "$start_time", + "end_time": "$end_time" + } + } + }, + { + "name": "nimble_list_deals", + "description": "List sales deals/opportunities.", + "parameters": { + "type": "object", + "properties": { + "stage": { "type": "string", "description": "Filter by stage name." }, + "owner": { "type": "string", "description": "Filter by owner ID." }, + "page": { "type": "integer", "description": "1-based." }, + "per_page": { "type": "integer", "description": "Max 30." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/deals", + "queryParams": { + "stage": "$stage", + "owner": "$owner", + "page": "$page", + "per_page": "$per_page" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/nimble.live.spec.ts b/packages/backend/src/adapters/intl/nimble.live.spec.ts new file mode 100644 index 0000000..29e8441 --- /dev/null +++ b/packages/backend/src/adapters/intl/nimble.live.spec.ts @@ -0,0 +1,12 @@ +import * as adapter from './nimble.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: { tokenUrl: string } }; +}; +describe('nimble adapter — static spec conformance', () => { + it('app.nimble.com/api/v1 base URL', () => + expect(a.connector.baseUrl).toBe('https://app.nimble.com/api/v1')); + it('OAuth2 refresh-token flow', () => { + expect(a.connector.authType).toBe('OAUTH2'); + expect(a.connector.authConfig.tokenUrl).toBe('https://app.nimble.com/oauth/token'); + }); +}); diff --git a/packages/backend/src/adapters/intl/nutshell-crm.json b/packages/backend/src/adapters/intl/nutshell-crm.json new file mode 100644 index 0000000..81fde46 --- /dev/null +++ b/packages/backend/src/adapters/intl/nutshell-crm.json @@ -0,0 +1,241 @@ +{ + "slug": "nutshell-crm", + "name": "Nutshell CRM", + "description": "Manage Nutshell CRM (leads, contacts, accounts, activities, tasks) from any AI agent. 9 tools, basic-auth with API token.", + "instructions": "This connector wraps the Nutshell REST API v1 (app.nutshell.com/api/v1).\n\n**Setup**:\n1. Log into https://app.nutshell.com → top-right avatar → **Setup → API → New API key**.\n2. Note the `Username` (your Nutshell email) and the generated `API key`.\n3. Set `NUTSHELL_USERNAME` (email) and `NUTSHELL_API_KEY`.\n\n**Authentication**: HTTP Basic — username = your account email, password = the API key. Nutshell ALSO supports a legacy JSON-RPC API but the REST endpoints exposed here are easier to consume.\n\n**Entity types**: `Lead` (the main pipeline opportunity), `Contact` (person), `Account` (company), `Activity` (call/meeting log), `Task` (todo). Each has its own endpoint root.\n\n**Pagination**: `page` (1-based) + `limit` (max 100).\n\n**Rate limits**: 5 req/sec per user; 5k/day. 429 with `X-Rate-Limit-Remaining` header.\n\n**Out of scope here**: web-form leads inbox, email templates, pipeline analytics, custom-app builder.", + "region": "intl", + "category": "crm", + "icon": "nutshell-crm", + "docsUrl": "https://developers.nutshell.com/docs", + "requiredEnvVars": ["NUTSHELL_USERNAME", "NUTSHELL_API_KEY"], + "connector": { + "name": "Nutshell REST API v1", + "type": "REST", + "baseUrl": "https://app.nutshell.com/api/v1", + "authType": "BASIC_AUTH", + "authConfig": { + "username": "{{NUTSHELL_USERNAME}}", + "password": "{{NUTSHELL_API_KEY}}" + } + }, + "tools": [ + { + "name": "nutshell_list_leads", + "description": "List leads. Filter by status (open, won, lost, cancelled), assignee, source.", + "parameters": { + "type": "object", + "properties": { + "status": { "type": "string", "description": "open, won, lost, cancelled." }, + "assignee_id": { "type": "integer", "description": "User ID." }, + "page": { "type": "integer", "description": "1-based." }, + "limit": { "type": "integer", "description": "Max 100." }, + "sort": { "type": "string", "description": "id, value, name, createdTime, modifiedTime — prefix with - for desc." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/leads", + "queryParams": { + "status": "$status", + "assignee_id": "$assignee_id", + "page": "$page", + "limit": "$limit", + "sort": "$sort" + } + } + }, + { + "name": "nutshell_get_lead", + "description": "Get one lead by ID with full custom fields, products, activities.", + "parameters": { + "type": "object", + "properties": { + "id": { "type": "integer", "description": "Lead ID." } + }, + "required": ["id"] + }, + "endpointMapping": { "method": "GET", "path": "/leads/{id}" } + }, + { + "name": "nutshell_create_lead", + "description": "Create a lead. Required: `description`. Useful optional: `primaryAccount.id`, `primaryContact.id`, `assignee.id`, `value.amount`, `confidence`.", + "parameters": { + "type": "object", + "properties": { + "description": { "type": "string", "description": "Lead description." }, + "primaryAccount": { "type": "object", "description": "{id} of the account." }, + "primaryContact": { "type": "object", "description": "{id} of the contact." }, + "assignee": { "type": "object", "description": "{id} of the user." }, + "value": { "type": "object", "description": "{amount, currency} of the deal." }, + "confidence": { "type": "integer", "description": "0-100 probability." }, + "stage_id": { "type": "integer", "description": "Pipeline stage ID." } + }, + "required": ["description"] + }, + "endpointMapping": { + "method": "POST", + "path": "/leads", + "bodyMapping": { + "description": "$description", + "primaryAccount": "$primaryAccount", + "primaryContact": "$primaryContact", + "assignee": "$assignee", + "value": "$value", + "confidence": "$confidence", + "stageId": "$stage_id" + } + } + }, + { + "name": "nutshell_list_contacts", + "description": "List contacts. Filter by name, email, account.", + "parameters": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Filter by name substring." }, + "email": { "type": "string", "description": "Exact email match." }, + "account_id": { "type": "integer", "description": "Filter to one account." }, + "page": { "type": "integer", "description": "1-based." }, + "limit": { "type": "integer", "description": "Max 100." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/contacts", + "queryParams": { + "name": "$name", + "email": "$email", + "account_id": "$account_id", + "page": "$page", + "limit": "$limit" + } + } + }, + { + "name": "nutshell_create_contact", + "description": "Create a contact. Required: `name`. Common optional: `email`, `phone`, `accounts` (array of {id}).", + "parameters": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Full name." }, + "email": { "type": "array", "description": "Array of {value, type:'work'|'personal'}." }, + "phone": { "type": "array", "description": "Array of {value, type}." }, + "accounts": { "type": "array", "description": "Array of {id} account refs." }, + "description": { "type": "string", "description": "Notes." } + }, + "required": ["name"] + }, + "endpointMapping": { + "method": "POST", + "path": "/contacts", + "bodyMapping": { + "name": "$name", + "email": "$email", + "phone": "$phone", + "accounts": "$accounts", + "description": "$description" + } + } + }, + { + "name": "nutshell_list_accounts", + "description": "List accounts (companies).", + "parameters": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Filter by name substring." }, + "page": { "type": "integer", "description": "1-based." }, + "limit": { "type": "integer", "description": "Max 100." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/accounts", + "queryParams": { "name": "$name", "page": "$page", "limit": "$limit" } + } + }, + { + "name": "nutshell_create_account", + "description": "Create a company. Required: `name`.", + "parameters": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Company name." }, + "url": { "type": "array", "description": "Array of {value}." }, + "phone": { "type": "array", "description": "Array of {value, type}." }, + "industry": { "type": "object", "description": "{id} industry ref." } + }, + "required": ["name"] + }, + "endpointMapping": { + "method": "POST", + "path": "/accounts", + "bodyMapping": { + "name": "$name", + "url": "$url", + "phone": "$phone", + "industry": "$industry" + } + } + }, + { + "name": "nutshell_list_activities", + "description": "List activities (calls, meetings, emails, log notes).", + "parameters": { + "type": "object", + "properties": { + "type": { "type": "string", "description": "Activity type code." }, + "user_id": { "type": "integer", "description": "Filter by user." }, + "lead_id": { "type": "integer", "description": "Filter to one lead." }, + "from": { "type": "string", "description": "ISO 8601 date." }, + "to": { "type": "string", "description": "ISO 8601 date." }, + "page": { "type": "integer", "description": "1-based." }, + "limit": { "type": "integer", "description": "Max 100." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/activities", + "queryParams": { + "type": "$type", + "user_id": "$user_id", + "lead_id": "$lead_id", + "from": "$from", + "to": "$to", + "page": "$page", + "limit": "$limit" + } + } + }, + { + "name": "nutshell_create_task", + "description": "Create a task assigned to a user, optionally linked to a lead/contact/account.", + "parameters": { + "type": "object", + "properties": { + "title": { "type": "string", "description": "Task title." }, + "description": { "type": "string", "description": "Task body." }, + "assignee_id": { "type": "integer", "description": "User ID." }, + "due_time": { "type": "string", "description": "ISO 8601 datetime." }, + "lead_id": { "type": "integer", "description": "Related lead." }, + "contact_id": { "type": "integer", "description": "Related contact." }, + "account_id": { "type": "integer", "description": "Related account." } + }, + "required": ["title"] + }, + "endpointMapping": { + "method": "POST", + "path": "/tasks", + "bodyMapping": { + "title": "$title", + "description": "$description", + "assignee": { "id": "$assignee_id" }, + "dueTime": "$due_time", + "lead": { "id": "$lead_id" }, + "contact": { "id": "$contact_id" }, + "account": { "id": "$account_id" } + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/nutshell-crm.live.spec.ts b/packages/backend/src/adapters/intl/nutshell-crm.live.spec.ts new file mode 100644 index 0000000..f164da1 --- /dev/null +++ b/packages/backend/src/adapters/intl/nutshell-crm.live.spec.ts @@ -0,0 +1,8 @@ +import * as adapter from './nutshell-crm.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string } }; +describe('nutshell-crm adapter — static spec conformance', () => { + it('app.nutshell.com/api/v1 base URL', () => + expect(a.connector.baseUrl).toBe('https://app.nutshell.com/api/v1')); + it('Basic auth (email + API key)', () => + expect(a.connector.authType).toBe('BASIC_AUTH')); +}); diff --git a/packages/backend/src/adapters/intl/omnisend.json b/packages/backend/src/adapters/intl/omnisend.json new file mode 100644 index 0000000..3fa6d4c --- /dev/null +++ b/packages/backend/src/adapters/intl/omnisend.json @@ -0,0 +1,203 @@ +{ + "slug": "omnisend", + "name": "Omnisend", + "description": "Manage Omnisend (e-commerce marketing automation: contacts, campaigns, events, products, categories) from any AI agent. 9 tools, X-API-KEY auth.", + "instructions": "This connector wraps the Omnisend REST API v3 (api.omnisend.com/v3).\n\n**Setup**:\n1. Log into https://app.omnisend.com → top-right avatar → **Store settings → Integrations & API → API keys → Create API key**.\n2. Set `OMNISEND_API_KEY`.\n\n**Authentication**: `X-API-KEY: ${OMNISEND_API_KEY}`.\n\n**Contact statuses**: `subscribed`, `unsubscribed`, `nonSubscribed` — per channel (email vs SMS).\n\n**Custom events**: trigger automations via the events endpoint. Send an event with name + email/contactID + properties; Omnisend matches it to active workflows.\n\n**Pagination**: cursor-based via `paging.next`. `limit` (max 250).\n\n**Rate limits**: 400 req/min per token. 429 with `Retry-After`.\n\n**Out of scope here**: workflow editor, SMS sender ID setup, product feed sync (use the dedicated `/products` endpoint), GDPR-erasure flow.", + "region": "intl", + "category": "email", + "icon": "omnisend", + "docsUrl": "https://api-docs.omnisend.com/", + "requiredEnvVars": ["OMNISEND_API_KEY"], + "connector": { + "name": "Omnisend API v3", + "type": "REST", + "baseUrl": "https://api.omnisend.com/v3", + "authType": "API_KEY", + "authConfig": { + "headerName": "X-API-KEY", + "apiKey": "{{OMNISEND_API_KEY}}" + } + }, + "tools": [ + { + "name": "omnisend_list_contacts", + "description": "List contacts. Filter by email, status, channel.", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "Filter by email." }, + "status": { "type": "string", "description": "subscribed, unsubscribed, nonSubscribed." }, + "channel": { "type": "string", "description": "email or sms." }, + "limit": { "type": "integer", "description": "Max 250." }, + "offset": { "type": "integer", "description": "0-based offset." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/contacts", + "queryParams": { + "email": "$email", + "status": "$status", + "channel": "$channel", + "limit": "$limit", + "offset": "$offset" + } + } + }, + { + "name": "omnisend_get_contact", + "description": "Get one contact by Omnisend contactID OR email.", + "parameters": { + "type": "object", + "properties": { + "contact_id": { "type": "string", "description": "Contact ID." } + }, + "required": ["contact_id"] + }, + "endpointMapping": { "method": "GET", "path": "/contacts/{contact_id}" } + }, + { + "name": "omnisend_upsert_contact", + "description": "Create or update a contact. Required: `identifiers` (array of {type: 'email'|'phone', id, channels: {email: {status}}}). `tags`, `customProperties` optional.", + "parameters": { + "type": "object", + "properties": { + "identifiers": { "type": "array", "description": "Array of identifier objects." }, + "firstName": { "type": "string", "description": "First name." }, + "lastName": { "type": "string", "description": "Last name." }, + "tags": { "type": "array", "description": "Array of tag strings." }, + "customProperties": { "type": "object", "description": "Free-form custom field map." }, + "country": { "type": "string", "description": "ISO 3166-1 alpha-2 country code." } + }, + "required": ["identifiers"] + }, + "endpointMapping": { + "method": "POST", + "path": "/contacts", + "bodyMapping": { + "identifiers": "$identifiers", + "firstName": "$firstName", + "lastName": "$lastName", + "tags": "$tags", + "customProperties": "$customProperties", + "country": "$country" + } + } + }, + { + "name": "omnisend_trigger_event", + "description": "Trigger a custom event for a contact. Used by Omnisend automations as a workflow start node.", + "parameters": { + "type": "object", + "properties": { + "eventName": { "type": "string", "description": "Custom event name (matches workflow trigger)." }, + "email": { "type": "string", "description": "Contact email." }, + "phone": { "type": "string", "description": "Contact phone." }, + "contactID": { "type": "string", "description": "Omnisend contact ID (alternative to email/phone)." }, + "fields": { "type": "object", "description": "Event payload — free-form key/value." } + }, + "required": ["eventName"] + }, + "endpointMapping": { + "method": "POST", + "path": "/events", + "bodyMapping": { + "eventName": "$eventName", + "email": "$email", + "phone": "$phone", + "contactID": "$contactID", + "fields": "$fields" + } + } + }, + { + "name": "omnisend_list_campaigns", + "description": "List campaigns.", + "parameters": { + "type": "object", + "properties": { + "status": { "type": "string", "description": "draft, sending, sent, paused, scheduled, ..." }, + "type": { "type": "string", "description": "standard, ab, sms." }, + "limit": { "type": "integer", "description": "Max 250." }, + "offset": { "type": "integer", "description": "0-based." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/campaigns", + "queryParams": { + "status": "$status", + "type": "$type", + "limit": "$limit", + "offset": "$offset" + } + } + }, + { + "name": "omnisend_get_campaign", + "description": "Get one campaign with stats.", + "parameters": { + "type": "object", + "properties": { + "campaign_id": { "type": "string", "description": "Campaign ID." } + }, + "required": ["campaign_id"] + }, + "endpointMapping": { "method": "GET", "path": "/campaigns/{campaign_id}" } + }, + { + "name": "omnisend_list_products", + "description": "List products in the Omnisend catalog (used by product-recommendation blocks).", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Max 250." }, + "offset": { "type": "integer", "description": "0-based." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/products", + "queryParams": { "limit": "$limit", "offset": "$offset" } + } + }, + { + "name": "omnisend_upsert_product", + "description": "Add or update a product in the catalog.", + "parameters": { + "type": "object", + "properties": { + "productID": { "type": "string", "description": "Your internal product ID." }, + "title": { "type": "string", "description": "Product title." }, + "status": { "type": "string", "description": "inStock, outOfStock, notAvailable." }, + "description": { "type": "string", "description": "Description." }, + "currency": { "type": "string", "description": "ISO 4217." }, + "productUrl": { "type": "string", "description": "Public URL." }, + "imageUrl": { "type": "string", "description": "Product image URL." }, + "variants": { "type": "array", "description": "Array of variant objects." } + }, + "required": ["productID", "title"] + }, + "endpointMapping": { + "method": "POST", + "path": "/products", + "bodyMapping": { + "productID": "$productID", + "title": "$title", + "status": "$status", + "description": "$description", + "currency": "$currency", + "productUrl": "$productUrl", + "imageUrl": "$imageUrl", + "variants": "$variants" + } + } + }, + { + "name": "omnisend_list_categories", + "description": "List product categories.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/categories" } + } + ] +} diff --git a/packages/backend/src/adapters/intl/omnisend.live.spec.ts b/packages/backend/src/adapters/intl/omnisend.live.spec.ts new file mode 100644 index 0000000..1dbf10d --- /dev/null +++ b/packages/backend/src/adapters/intl/omnisend.live.spec.ts @@ -0,0 +1,12 @@ +import * as adapter from './omnisend.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: { headerName: string } }; +}; +describe('omnisend adapter — static spec conformance', () => { + it('api.omnisend.com/v3 base URL', () => + expect(a.connector.baseUrl).toBe('https://api.omnisend.com/v3')); + it('X-API-KEY header auth', () => { + expect(a.connector.authType).toBe('API_KEY'); + expect(a.connector.authConfig.headerName).toBe('X-API-KEY'); + }); +}); diff --git a/packages/backend/src/adapters/intl/pinterest.json b/packages/backend/src/adapters/intl/pinterest.json new file mode 100644 index 0000000..ad4c87a --- /dev/null +++ b/packages/backend/src/adapters/intl/pinterest.json @@ -0,0 +1,183 @@ +{ + "slug": "pinterest", + "name": "Pinterest", + "description": "Manage Pinterest (boards, pins, search, ads, audiences) from any AI agent via Business API. 9 tools, OAuth2 token.", + "instructions": "This connector wraps the Pinterest Business API v5 (api.pinterest.com/v5).\n\n**Setup — OAuth2**:\n1. Register at https://developers.pinterest.com → **Create app**.\n2. Configure redirect URI, run the OAuth flow at `https://www.pinterest.com/oauth/?response_type=code&client_id=...&redirect_uri=...&scope=boards:read,boards:write,pins:read,pins:write,user_accounts:read`.\n3. Exchange the code at `https://api.pinterest.com/v5/oauth/token`.\n4. Set `PINTEREST_CLIENT_ID`, `PINTEREST_CLIENT_SECRET`, `PINTEREST_REFRESH_TOKEN`.\n\n**Authentication**: OAuth2 with refresh — engine handles. Sends `Authorization: Bearer ACCESS_TOKEN`.\n\n**Sandbox available**: change baseUrl to `api-sandbox.pinterest.com/v5` for testing.\n\n**Pin formats**: standard pins point at an external image URL (`media_source: {source_type:'image_url', url:'...'}`) or use the upload endpoint for direct upload (multipart, out of scope here). Video pins require a separate upload flow.\n\n**Pagination**: `bookmark` (cursor) + `page_size` (default 25, max 100).\n\n**Rate limits**: 1000 req / hour per token (varies by endpoint). 429 with `Retry-After`.\n\n**Out of scope here**: video upload multipart, catalog feeds, ad campaign creation, trends, conversion API.", + "region": "intl", + "category": "social", + "icon": "pinterest", + "docsUrl": "https://developers.pinterest.com/docs/api/v5/", + "requiredEnvVars": ["PINTEREST_CLIENT_ID", "PINTEREST_CLIENT_SECRET", "PINTEREST_REFRESH_TOKEN"], + "connector": { + "name": "Pinterest Business API v5", + "type": "REST", + "baseUrl": "https://api.pinterest.com/v5", + "authType": "OAUTH2", + "authConfig": { + "clientId": "{{PINTEREST_CLIENT_ID}}", + "clientSecret": "{{PINTEREST_CLIENT_SECRET}}", + "refreshToken": "{{PINTEREST_REFRESH_TOKEN}}", + "tokenUrl": "https://api.pinterest.com/v5/oauth/token" + } + }, + "tools": [ + { + "name": "pinterest_user_account", + "description": "Get the authenticated user's account (username, account_type, profile_image, board_count, follower_count).", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/user_account" } + }, + { + "name": "pinterest_list_boards", + "description": "List the authenticated user's boards.", + "parameters": { + "type": "object", + "properties": { + "page_size": { "type": "integer", "description": "Max 100." }, + "bookmark": { "type": "string", "description": "Cursor." }, + "privacy": { "type": "string", "description": "PUBLIC, SECRET, PROTECTED." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/boards", + "queryParams": { + "page_size": "$page_size", + "bookmark": "$bookmark", + "privacy": "$privacy" + } + } + }, + { + "name": "pinterest_get_board", + "description": "Get a single board with section + pin counts.", + "parameters": { + "type": "object", + "properties": { + "board_id": { "type": "string", "description": "Board ID." } + }, + "required": ["board_id"] + }, + "endpointMapping": { "method": "GET", "path": "/boards/{board_id}" } + }, + { + "name": "pinterest_create_board", + "description": "Create a board.", + "parameters": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Board name." }, + "description": { "type": "string", "description": "Description." }, + "privacy": { "type": "string", "description": "PUBLIC, SECRET, PROTECTED." } + }, + "required": ["name"] + }, + "endpointMapping": { + "method": "POST", + "path": "/boards", + "bodyMapping": { + "name": "$name", + "description": "$description", + "privacy": "$privacy" + } + } + }, + { + "name": "pinterest_list_board_pins", + "description": "List pins in a board.", + "parameters": { + "type": "object", + "properties": { + "board_id": { "type": "string", "description": "Board ID." }, + "page_size": { "type": "integer", "description": "Max 100." }, + "bookmark": { "type": "string", "description": "Cursor." } + }, + "required": ["board_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/boards/{board_id}/pins", + "queryParams": { + "page_size": "$page_size", + "bookmark": "$bookmark" + } + } + }, + { + "name": "pinterest_get_pin", + "description": "Get a single pin with full media info.", + "parameters": { + "type": "object", + "properties": { + "pin_id": { "type": "string", "description": "Pin ID." } + }, + "required": ["pin_id"] + }, + "endpointMapping": { "method": "GET", "path": "/pins/{pin_id}" } + }, + { + "name": "pinterest_create_pin", + "description": "Create a pin on a board from an external image URL. For uploading from a file, use the dedicated media upload endpoint (out of scope here).", + "parameters": { + "type": "object", + "properties": { + "board_id": { "type": "string", "description": "Board ID." }, + "title": { "type": "string", "description": "Pin title." }, + "description": { "type": "string", "description": "Description." }, + "link": { "type": "string", "description": "Destination URL when the pin is clicked." }, + "alt_text": { "type": "string", "description": "Alt text for accessibility." }, + "image_url": { "type": "string", "description": "Public URL of the image to pin." }, + "board_section_id": { "type": "string", "description": "Section ID inside the board." } + }, + "required": ["board_id", "image_url"] + }, + "endpointMapping": { + "method": "POST", + "path": "/pins", + "bodyMapping": { + "board_id": "$board_id", + "title": "$title", + "description": "$description", + "link": "$link", + "alt_text": "$alt_text", + "media_source": { "source_type": "image_url", "url": "$image_url" }, + "board_section_id": "$board_section_id" + } + } + }, + { + "name": "pinterest_delete_pin", + "description": "Delete a pin.", + "parameters": { + "type": "object", + "properties": { + "pin_id": { "type": "string", "description": "Pin ID." } + }, + "required": ["pin_id"] + }, + "endpointMapping": { "method": "DELETE", "path": "/pins/{pin_id}" } + }, + { + "name": "pinterest_search_pins", + "description": "Search the authenticated user's pins (across all their boards) by free-text query.", + "parameters": { + "type": "object", + "properties": { + "query": { "type": "string", "description": "Search query." }, + "page_size": { "type": "integer", "description": "Max 100." }, + "bookmark": { "type": "string", "description": "Cursor." } + }, + "required": ["query"] + }, + "endpointMapping": { + "method": "GET", + "path": "/search/pins", + "queryParams": { + "query": "$query", + "page_size": "$page_size", + "bookmark": "$bookmark" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/pinterest.live.spec.ts b/packages/backend/src/adapters/intl/pinterest.live.spec.ts new file mode 100644 index 0000000..2d98b31 --- /dev/null +++ b/packages/backend/src/adapters/intl/pinterest.live.spec.ts @@ -0,0 +1,8 @@ +import * as adapter from './pinterest.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string } }; +describe('pinterest adapter — static spec conformance', () => { + it('api.pinterest.com/v5 base URL', () => + expect(a.connector.baseUrl).toBe('https://api.pinterest.com/v5')); + it('OAuth2 with refresh-token flow', () => + expect(a.connector.authType).toBe('OAUTH2')); +}); diff --git a/packages/backend/src/adapters/intl/plaid.json b/packages/backend/src/adapters/intl/plaid.json new file mode 100644 index 0000000..38b0230 --- /dev/null +++ b/packages/backend/src/adapters/intl/plaid.json @@ -0,0 +1,246 @@ +{ + "slug": "plaid", + "name": "Plaid", + "description": "Read Plaid (bank-linking, transactions, balances, identity, liabilities, investments) from any AI agent. 10 tools, body-credential auth.", + "instructions": "This connector wraps the Plaid REST API (per-environment).\n\n**Setup**:\n1. Sign up at https://dashboard.plaid.com → **Team Settings → Keys**. Note your `client_id` and one of: `sandbox`, `development`, `production` secret.\n2. **Environment matters**: change baseUrl: `sandbox.plaid.com` (free, fake banks), `development.plaid.com` (real banks, 100 free items), `production.plaid.com` (paid). Defaults to sandbox.\n3. Set `PLAID_CLIENT_ID` and `PLAID_SECRET`.\n\n**Authentication**: every POST sends `client_id` + `secret` in the JSON body — NOT as headers. The adapter merges them automatically via envVars.\n\n**Link flow**: Plaid is server+client — to obtain an `access_token`, your frontend uses Plaid Link with a `link_token` you minted server-side. This adapter assumes you already have an `access_token` for an Item (one connected bank). For Link itself use `plaid_link_token_create`.\n\n**Identifiers**:\n- `link_token`: short-lived (~30 min), passed to Link.\n- `public_token`: returned by Link, exchanged once for an access_token.\n- `access_token`: long-lived, scoped to one Item (institution+user).\n- `item_id`: stable ID for an access_token's Item.\n\n**Rate limits**: vary by environment + product. Sandbox is loose; production limits depend on plan. 429 with `Retry-After`.\n\n**Out of scope here**: webhooks setup, Plaid Signal (risk scores), Income/Employment products' end-user flows, Transfer (ACH origination).", + "region": "intl", + "category": "payments", + "icon": "plaid", + "docsUrl": "https://plaid.com/docs/api/", + "requiredEnvVars": ["PLAID_CLIENT_ID", "PLAID_SECRET"], + "connector": { + "name": "Plaid API", + "type": "REST", + "baseUrl": "https://sandbox.plaid.com", + "authType": "NONE", + "authConfig": {} + }, + "tools": [ + { + "name": "plaid_link_token_create", + "description": "Create a short-lived link_token to pass to the Plaid Link client. The client returns a public_token after the user authenticates with their bank; exchange that for an access_token via plaid_item_public_token_exchange.", + "parameters": { + "type": "object", + "properties": { + "user_client_user_id": { "type": "string", "description": "Your unique ID for this end-user (will be associated with the Item)." }, + "client_name": { "type": "string", "description": "Display name shown in Link UI." }, + "products": { "type": "array", "description": "Array: transactions, auth, identity, assets, investments, liabilities, ..." }, + "country_codes": { "type": "array", "description": "ISO 3166-1 alpha-2 list, e.g. ['US']." }, + "language": { "type": "string", "description": "en, es, fr, ..." }, + "webhook": { "type": "string", "description": "Webhook URL for Item updates." } + }, + "required": ["user_client_user_id", "client_name", "products", "country_codes", "language"] + }, + "endpointMapping": { + "method": "POST", + "path": "/link/token/create", + "bodyMapping": { + "client_id": "$PLAID_CLIENT_ID", + "secret": "$PLAID_SECRET", + "user": { "client_user_id": "$user_client_user_id" }, + "client_name": "$client_name", + "products": "$products", + "country_codes": "$country_codes", + "language": "$language", + "webhook": "$webhook" + } + } + }, + { + "name": "plaid_item_public_token_exchange", + "description": "Exchange a public_token (from Plaid Link) for a long-lived access_token + item_id.", + "parameters": { + "type": "object", + "properties": { + "public_token": { "type": "string", "description": "public_token from Link." } + }, + "required": ["public_token"] + }, + "endpointMapping": { + "method": "POST", + "path": "/item/public_token/exchange", + "bodyMapping": { + "client_id": "$PLAID_CLIENT_ID", + "secret": "$PLAID_SECRET", + "public_token": "$public_token" + } + } + }, + { + "name": "plaid_item_get", + "description": "Get metadata about an Item (institution, products, billed products, status, error).", + "parameters": { + "type": "object", + "properties": { + "access_token": { "type": "string", "description": "Item access token." } + }, + "required": ["access_token"] + }, + "endpointMapping": { + "method": "POST", + "path": "/item/get", + "bodyMapping": { + "client_id": "$PLAID_CLIENT_ID", + "secret": "$PLAID_SECRET", + "access_token": "$access_token" + } + } + }, + { + "name": "plaid_accounts_get", + "description": "List accounts for an Item with name, mask, type (depository, credit, loan, investment), subtype.", + "parameters": { + "type": "object", + "properties": { + "access_token": { "type": "string", "description": "Item access token." } + }, + "required": ["access_token"] + }, + "endpointMapping": { + "method": "POST", + "path": "/accounts/get", + "bodyMapping": { + "client_id": "$PLAID_CLIENT_ID", + "secret": "$PLAID_SECRET", + "access_token": "$access_token" + } + } + }, + { + "name": "plaid_accounts_balance_get", + "description": "Get real-time balances for an Item's accounts (may trigger a refresh — slower than accounts_get).", + "parameters": { + "type": "object", + "properties": { + "access_token": { "type": "string", "description": "Item access token." }, + "options": { "type": "object", "description": "{account_ids: [...]} to subset." } + }, + "required": ["access_token"] + }, + "endpointMapping": { + "method": "POST", + "path": "/accounts/balance/get", + "bodyMapping": { + "client_id": "$PLAID_CLIENT_ID", + "secret": "$PLAID_SECRET", + "access_token": "$access_token", + "options": "$options" + } + } + }, + { + "name": "plaid_transactions_get", + "description": "Get transactions in a date range (up to 730 days back). Paginated via `count` + `offset`. Note: for ongoing sync prefer `/transactions/sync`.", + "parameters": { + "type": "object", + "properties": { + "access_token": { "type": "string", "description": "Item access token." }, + "start_date": { "type": "string", "description": "YYYY-MM-DD." }, + "end_date": { "type": "string", "description": "YYYY-MM-DD." }, + "options": { "type": "object", "description": "{count: 500, offset: 0, account_ids: [...]}." } + }, + "required": ["access_token", "start_date", "end_date"] + }, + "endpointMapping": { + "method": "POST", + "path": "/transactions/get", + "bodyMapping": { + "client_id": "$PLAID_CLIENT_ID", + "secret": "$PLAID_SECRET", + "access_token": "$access_token", + "start_date": "$start_date", + "end_date": "$end_date", + "options": "$options" + } + } + }, + { + "name": "plaid_transactions_sync", + "description": "Incremental transactions sync. Pass `cursor` from previous response (empty string for first call). Returns `added`, `modified`, `removed`, `next_cursor`, `has_more`.", + "parameters": { + "type": "object", + "properties": { + "access_token": { "type": "string", "description": "Item access token." }, + "cursor": { "type": "string", "description": "Cursor (empty string on first sync)." }, + "count": { "type": "integer", "description": "Max per page (500)." } + }, + "required": ["access_token"] + }, + "endpointMapping": { + "method": "POST", + "path": "/transactions/sync", + "bodyMapping": { + "client_id": "$PLAID_CLIENT_ID", + "secret": "$PLAID_SECRET", + "access_token": "$access_token", + "cursor": "$cursor", + "count": "$count" + } + } + }, + { + "name": "plaid_identity_get", + "description": "Get identity info attached to the Item's accounts (names, emails, addresses, phone numbers).", + "parameters": { + "type": "object", + "properties": { + "access_token": { "type": "string", "description": "Item access token." } + }, + "required": ["access_token"] + }, + "endpointMapping": { + "method": "POST", + "path": "/identity/get", + "bodyMapping": { + "client_id": "$PLAID_CLIENT_ID", + "secret": "$PLAID_SECRET", + "access_token": "$access_token" + } + } + }, + { + "name": "plaid_auth_get", + "description": "Get bank routing + account numbers for ACH/EFT setup. Requires `auth` product on the Item.", + "parameters": { + "type": "object", + "properties": { + "access_token": { "type": "string", "description": "Item access token." } + }, + "required": ["access_token"] + }, + "endpointMapping": { + "method": "POST", + "path": "/auth/get", + "bodyMapping": { + "client_id": "$PLAID_CLIENT_ID", + "secret": "$PLAID_SECRET", + "access_token": "$access_token" + } + } + }, + { + "name": "plaid_institutions_search", + "description": "Search Plaid's catalog of supported financial institutions by name + country.", + "parameters": { + "type": "object", + "properties": { + "query": { "type": "string", "description": "Institution name fragment." }, + "products": { "type": "array", "description": "Array of products the institution must support." }, + "country_codes": { "type": "array", "description": "ISO 3166-1 alpha-2 list." } + }, + "required": ["query", "country_codes"] + }, + "endpointMapping": { + "method": "POST", + "path": "/institutions/search", + "bodyMapping": { + "client_id": "$PLAID_CLIENT_ID", + "secret": "$PLAID_SECRET", + "query": "$query", + "products": "$products", + "country_codes": "$country_codes" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/plaid.live.spec.ts b/packages/backend/src/adapters/intl/plaid.live.spec.ts new file mode 100644 index 0000000..e1bba1b --- /dev/null +++ b/packages/backend/src/adapters/intl/plaid.live.spec.ts @@ -0,0 +1,16 @@ +import * as adapter from './plaid.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string }; + tools: Array<{ endpointMapping: { bodyMapping?: Record } }>; +}; +describe('plaid adapter — static spec conformance', () => { + it('sandbox.plaid.com base URL (safe default)', () => + expect(a.connector.baseUrl).toBe('https://sandbox.plaid.com')); + it('every tool injects client_id + secret into the body', () => { + for (const t of a.tools) { + const body = t.endpointMapping.bodyMapping ?? {}; + expect(body.client_id).toBe('$PLAID_CLIENT_ID'); + expect(body.secret).toBe('$PLAID_SECRET'); + } + }); +}); diff --git a/packages/backend/src/adapters/intl/plane-so.json b/packages/backend/src/adapters/intl/plane-so.json new file mode 100644 index 0000000..a3e633c --- /dev/null +++ b/packages/backend/src/adapters/intl/plane-so.json @@ -0,0 +1,220 @@ +{ + "slug": "plane-so", + "name": "Plane", + "description": "Manage Plane (open-source project management) workspaces, projects, issues, cycles, modules from any AI agent. 10 tools, API key auth.", + "instructions": "This connector wraps the Plane REST API v1 (api.plane.so/api/v1 — or your self-hosted host).\n\n**Setup**:\n1. Log into your Plane instance → top-right avatar → **API Tokens → Create new token**.\n2. Note the **workspace slug** (the part of URLs like `app.plane.so//projects`).\n3. Set `PLANE_API_KEY`. For self-hosted, also set `PLANE_HOST` (e.g. `https://plane.yourdomain.com`) — defaults to cloud (api.plane.so).\n\n**Authentication**: `X-API-Key: ${PLANE_API_KEY}`.\n\n**Hierarchy**: `Workspace → Project → Issue → Sub-issue`. Cycles + Modules are issue containers within a project for sprint planning / feature grouping.\n\n**Pagination**: `per_page` (max 100) + `cursor` (opaque, returned in response).\n\n**Rate limits**: 60 req/min on cloud. 429 with `Retry-After`.\n\n**Out of scope here**: workflow automation, view layout CRUD, document pages editor, the GraphQL backend used by the web app (not officially supported for third-party use).", + "region": "intl", + "category": "project-management", + "icon": "plane-so", + "docsUrl": "https://docs.plane.so/api-reference/introduction", + "requiredEnvVars": ["PLANE_API_KEY"], + "connector": { + "name": "Plane API v1", + "type": "REST", + "baseUrl": "https://api.plane.so/api/v1", + "authType": "API_KEY", + "authConfig": { + "headerName": "X-API-Key", + "apiKey": "{{PLANE_API_KEY}}" + } + }, + "tools": [ + { + "name": "plane_list_workspaces", + "description": "List all workspaces the API key can access.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/workspaces/" } + }, + { + "name": "plane_list_projects", + "description": "List projects in a workspace.", + "parameters": { + "type": "object", + "properties": { + "workspace_slug": { "type": "string", "description": "Workspace slug." } + }, + "required": ["workspace_slug"] + }, + "endpointMapping": { + "method": "GET", + "path": "/workspaces/{workspace_slug}/projects/" + } + }, + { + "name": "plane_get_project", + "description": "Get one project.", + "parameters": { + "type": "object", + "properties": { + "workspace_slug": { "type": "string", "description": "Workspace slug." }, + "project_id": { "type": "string", "description": "Project UUID." } + }, + "required": ["workspace_slug", "project_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/workspaces/{workspace_slug}/projects/{project_id}/" + } + }, + { + "name": "plane_list_issues", + "description": "List issues in a project. Filter by state, priority, assignee.", + "parameters": { + "type": "object", + "properties": { + "workspace_slug": { "type": "string", "description": "Workspace slug." }, + "project_id": { "type": "string", "description": "Project UUID." }, + "state": { "type": "string", "description": "Filter by state UUID." }, + "priority": { "type": "string", "description": "urgent, high, medium, low, none." }, + "assignees": { "type": "string", "description": "Comma-separated user UUIDs." }, + "per_page": { "type": "integer", "description": "Max 100." }, + "cursor": { "type": "string", "description": "Pagination cursor." } + }, + "required": ["workspace_slug", "project_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/workspaces/{workspace_slug}/projects/{project_id}/issues/", + "queryParams": { + "state": "$state", + "priority": "$priority", + "assignees": "$assignees", + "per_page": "$per_page", + "cursor": "$cursor" + } + } + }, + { + "name": "plane_get_issue", + "description": "Get one issue with full description + custom properties.", + "parameters": { + "type": "object", + "properties": { + "workspace_slug": { "type": "string", "description": "Workspace slug." }, + "project_id": { "type": "string", "description": "Project UUID." }, + "issue_id": { "type": "string", "description": "Issue UUID." } + }, + "required": ["workspace_slug", "project_id", "issue_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/workspaces/{workspace_slug}/projects/{project_id}/issues/{issue_id}/" + } + }, + { + "name": "plane_create_issue", + "description": "Create an issue.", + "parameters": { + "type": "object", + "properties": { + "workspace_slug": { "type": "string", "description": "Workspace slug." }, + "project_id": { "type": "string", "description": "Project UUID." }, + "name": { "type": "string", "description": "Issue title." }, + "description_html": { "type": "string", "description": "HTML description." }, + "state": { "type": "string", "description": "State UUID." }, + "priority": { "type": "string", "description": "urgent, high, medium, low, none." }, + "assignees": { "type": "array", "description": "Array of user UUIDs." }, + "labels": { "type": "array", "description": "Array of label UUIDs." }, + "parent": { "type": "string", "description": "Parent issue UUID (for sub-issues)." }, + "estimate_point": { "type": "integer", "description": "Estimate point." }, + "start_date": { "type": "string", "description": "YYYY-MM-DD." }, + "target_date": { "type": "string", "description": "YYYY-MM-DD." } + }, + "required": ["workspace_slug", "project_id", "name"] + }, + "endpointMapping": { + "method": "POST", + "path": "/workspaces/{workspace_slug}/projects/{project_id}/issues/", + "bodyMapping": { + "name": "$name", + "description_html": "$description_html", + "state": "$state", + "priority": "$priority", + "assignees": "$assignees", + "labels": "$labels", + "parent": "$parent", + "estimate_point": "$estimate_point", + "start_date": "$start_date", + "target_date": "$target_date" + } + } + }, + { + "name": "plane_update_issue", + "description": "Update an issue (PATCH semantics).", + "parameters": { + "type": "object", + "properties": { + "workspace_slug": { "type": "string", "description": "Workspace slug." }, + "project_id": { "type": "string", "description": "Project UUID." }, + "issue_id": { "type": "string", "description": "Issue UUID." }, + "name": { "type": "string", "description": "New title." }, + "state": { "type": "string", "description": "Move to state UUID." }, + "priority": { "type": "string", "description": "New priority." }, + "assignees": { "type": "array", "description": "Replace assignees." }, + "description_html": { "type": "string", "description": "New HTML description." } + }, + "required": ["workspace_slug", "project_id", "issue_id"] + }, + "endpointMapping": { + "method": "PATCH", + "path": "/workspaces/{workspace_slug}/projects/{project_id}/issues/{issue_id}/", + "bodyMapping": { + "name": "$name", + "state": "$state", + "priority": "$priority", + "assignees": "$assignees", + "description_html": "$description_html" + } + } + }, + { + "name": "plane_list_states", + "description": "List states (issue statuses) defined in a project. Use to discover state UUIDs.", + "parameters": { + "type": "object", + "properties": { + "workspace_slug": { "type": "string", "description": "Workspace slug." }, + "project_id": { "type": "string", "description": "Project UUID." } + }, + "required": ["workspace_slug", "project_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/workspaces/{workspace_slug}/projects/{project_id}/states/" + } + }, + { + "name": "plane_list_cycles", + "description": "List cycles (sprints) in a project.", + "parameters": { + "type": "object", + "properties": { + "workspace_slug": { "type": "string", "description": "Workspace slug." }, + "project_id": { "type": "string", "description": "Project UUID." } + }, + "required": ["workspace_slug", "project_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/workspaces/{workspace_slug}/projects/{project_id}/cycles/" + } + }, + { + "name": "plane_list_modules", + "description": "List modules (feature groups) in a project.", + "parameters": { + "type": "object", + "properties": { + "workspace_slug": { "type": "string", "description": "Workspace slug." }, + "project_id": { "type": "string", "description": "Project UUID." } + }, + "required": ["workspace_slug", "project_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/workspaces/{workspace_slug}/projects/{project_id}/modules/" + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/plane-so.live.spec.ts b/packages/backend/src/adapters/intl/plane-so.live.spec.ts new file mode 100644 index 0000000..d7603ca --- /dev/null +++ b/packages/backend/src/adapters/intl/plane-so.live.spec.ts @@ -0,0 +1,12 @@ +import * as adapter from './plane-so.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: { headerName: string } }; +}; +describe('plane-so adapter — static spec conformance', () => { + it('api.plane.so/api/v1 base URL (cloud default)', () => + expect(a.connector.baseUrl).toBe('https://api.plane.so/api/v1')); + it('X-API-Key header auth', () => { + expect(a.connector.authType).toBe('API_KEY'); + expect(a.connector.authConfig.headerName).toBe('X-API-Key'); + }); +}); diff --git a/packages/backend/src/adapters/intl/sage-business-cloud.json b/packages/backend/src/adapters/intl/sage-business-cloud.json new file mode 100644 index 0000000..11ef5c2 --- /dev/null +++ b/packages/backend/src/adapters/intl/sage-business-cloud.json @@ -0,0 +1,182 @@ +{ + "slug": "sage-business-cloud", + "name": "Sage Business Cloud Accounting", + "description": "Manage Sage Business Cloud Accounting (contacts, products, invoices, bank accounts, transactions) from any AI agent. 9 tools, OAuth2 auth.", + "instructions": "This connector wraps the Sage Business Cloud Accounting API v3.1 (api.accounting.sage.com).\n\n**Setup — OAuth2**:\n1. Register at https://developer.sage.com/accounting → **Create app**. Set callback URL.\n2. Complete the auth flow at `https://www.sageone.com/oauth2/auth/central?response_type=code&client_id=...&redirect_uri=...&scope=full_access&country=gb` (use the country code matching your Sage subscription — gb, us, ca, de, fr, ie, es).\n3. Exchange the code at `https://oauth.accounting.sage.com/token`.\n4. Set `SAGE_CLIENT_ID`, `SAGE_CLIENT_SECRET`, `SAGE_REFRESH_TOKEN`.\n\n**Authentication**: OAuth2 — engine handles refresh. Sends `Authorization: Bearer ${ACCESS_TOKEN}`.\n\n**Business ID header**: Sage scopes everything to a business. Pass `X-Business: ` header on each call OR rely on the default business of the user. To discover IDs: `GET /v3.1/businesses`.\n\n**Pagination**: `page` (1-based) + `items_per_page` (max 200).\n\n**Rate limits**: 100 req/min per token, 10k/day. 429 with `Retry-After`.\n\n**Out of scope here**: VAT return submission, Stripe-connect setup, multi-currency journal lines beyond standard, asset register.", + "region": "intl", + "category": "accounting", + "icon": "sage-business-cloud", + "docsUrl": "https://developer.sage.com/accounting/reference/", + "requiredEnvVars": ["SAGE_CLIENT_ID", "SAGE_CLIENT_SECRET", "SAGE_REFRESH_TOKEN"], + "connector": { + "name": "Sage Business Cloud Accounting v3.1", + "type": "REST", + "baseUrl": "https://api.accounting.sage.com/v3.1", + "authType": "OAUTH2", + "authConfig": { + "clientId": "{{SAGE_CLIENT_ID}}", + "clientSecret": "{{SAGE_CLIENT_SECRET}}", + "refreshToken": "{{SAGE_REFRESH_TOKEN}}", + "tokenUrl": "https://oauth.accounting.sage.com/token" + } + }, + "tools": [ + { + "name": "sage_list_businesses", + "description": "List all businesses the authenticated user can access. Use this first to discover business IDs.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/businesses" } + }, + { + "name": "sage_list_contacts", + "description": "List contacts (customers + vendors).", + "parameters": { + "type": "object", + "properties": { + "search": { "type": "string", "description": "Name/email substring search." }, + "contact_type_id": { "type": "string", "description": "Filter: CUSTOMER, VENDOR." }, + "page": { "type": "integer", "description": "1-based." }, + "items_per_page": { "type": "integer", "description": "Max 200." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/contacts", + "queryParams": { + "search": "$search", + "contact_type_id": "$contact_type_id", + "page": "$page", + "items_per_page": "$items_per_page" + } + } + }, + { + "name": "sage_get_contact", + "description": "Get one contact by ID with full address + tax info.", + "parameters": { + "type": "object", + "properties": { + "id": { "type": "string", "description": "Contact ID." } + }, + "required": ["id"] + }, + "endpointMapping": { "method": "GET", "path": "/contacts/{id}" } + }, + { + "name": "sage_create_contact", + "description": "Create a contact. `contact_type_ids` is an array of contact types.", + "parameters": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Contact name." }, + "contact_type_ids": { "type": "array", "description": "Array of 'CUSTOMER' / 'VENDOR'." }, + "email": { "type": "string", "description": "Email." }, + "main_address": { "type": "object", "description": "{address_type_id, address_line_1, city, postal_code, country_id}." } + }, + "required": ["name", "contact_type_ids"] + }, + "endpointMapping": { + "method": "POST", + "path": "/contacts", + "bodyMapping": { + "contact": { + "name": "$name", + "contact_type_ids": "$contact_type_ids", + "email": "$email", + "main_address": "$main_address" + } + } + } + }, + { + "name": "sage_list_sales_invoices", + "description": "List sales (customer) invoices.", + "parameters": { + "type": "object", + "properties": { + "contact_id": { "type": "string", "description": "Filter by customer." }, + "from_date": { "type": "string", "description": "YYYY-MM-DD." }, + "to_date": { "type": "string", "description": "YYYY-MM-DD." }, + "status_id": { "type": "string", "description": "DRAFT, COMPLETE, VOIDED." }, + "page": { "type": "integer", "description": "1-based." }, + "items_per_page": { "type": "integer", "description": "Max 200." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/sales_invoices", + "queryParams": { + "contact_id": "$contact_id", + "from_date": "$from_date", + "to_date": "$to_date", + "status_id": "$status_id", + "page": "$page", + "items_per_page": "$items_per_page" + } + } + }, + { + "name": "sage_get_sales_invoice", + "description": "Get one sales invoice with lines + applied payments.", + "parameters": { + "type": "object", + "properties": { + "id": { "type": "string", "description": "Invoice ID." } + }, + "required": ["id"] + }, + "endpointMapping": { "method": "GET", "path": "/sales_invoices/{id}" } + }, + { + "name": "sage_list_products", + "description": "List products and services.", + "parameters": { + "type": "object", + "properties": { + "search": { "type": "string", "description": "Name search." }, + "page": { "type": "integer", "description": "1-based." }, + "items_per_page": { "type": "integer", "description": "Max 200." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/products", + "queryParams": { + "search": "$search", + "page": "$page", + "items_per_page": "$items_per_page" + } + } + }, + { + "name": "sage_list_bank_accounts", + "description": "List bank accounts.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/bank_accounts" } + }, + { + "name": "sage_list_transactions", + "description": "List bank transactions on an account.", + "parameters": { + "type": "object", + "properties": { + "bank_account_id": { "type": "string", "description": "Bank account ID." }, + "from_date": { "type": "string", "description": "YYYY-MM-DD." }, + "to_date": { "type": "string", "description": "YYYY-MM-DD." }, + "page": { "type": "integer", "description": "1-based." } + }, + "required": ["bank_account_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/bank_transactions", + "queryParams": { + "bank_account_id": "$bank_account_id", + "from_date": "$from_date", + "to_date": "$to_date", + "page": "$page" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/sage-business-cloud.live.spec.ts b/packages/backend/src/adapters/intl/sage-business-cloud.live.spec.ts new file mode 100644 index 0000000..b30adc3 --- /dev/null +++ b/packages/backend/src/adapters/intl/sage-business-cloud.live.spec.ts @@ -0,0 +1,12 @@ +import * as adapter from './sage-business-cloud.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: { tokenUrl: string } }; +}; +describe('sage-business-cloud adapter — static spec conformance', () => { + it('api.accounting.sage.com/v3.1 base URL', () => + expect(a.connector.baseUrl).toBe('https://api.accounting.sage.com/v3.1')); + it('OAuth2 with Sage token endpoint', () => { + expect(a.connector.authType).toBe('OAUTH2'); + expect(a.connector.authConfig.tokenUrl).toBe('https://oauth.accounting.sage.com/token'); + }); +}); diff --git a/packages/backend/src/adapters/intl/salesflare.json b/packages/backend/src/adapters/intl/salesflare.json new file mode 100644 index 0000000..c433ffc --- /dev/null +++ b/packages/backend/src/adapters/intl/salesflare.json @@ -0,0 +1,241 @@ +{ + "slug": "salesflare", + "name": "Salesflare", + "description": "Manage Salesflare CRM (contacts, accounts, opportunities, tasks, custom fields, tags) from any AI agent. 9 tools, API key header auth.", + "instructions": "This connector wraps the Salesflare REST API v1 (api.salesflare.com).\n\n**Setup**:\n1. Log into https://app.salesflare.com → **Settings → Personal API key → Generate**.\n2. Set `SALESFLARE_API_KEY`.\n\n**Authentication**: `Authorization: ${SALESFLARE_API_KEY}` (the API key is sent RAW as the Authorization header value — NOT `Bearer X`, not `Basic X`). The adapter wires this as a custom-name API_KEY auth.\n\n**Entity model**: `Account` (company) → `Contact` (person at the account) → `Opportunity` (deal). Tasks + interactions hang off any of these.\n\n**Pagination**: `limit` (max 200) + `offset` (0-based).\n\n**Rate limits**: 60 req/min per key. 429 with `Retry-After`.\n\n**Out of scope here**: email/calendar sync settings, workflow editor, the Magic Bar Chrome extension hooks.", + "region": "intl", + "category": "crm", + "icon": "salesflare", + "docsUrl": "https://api.salesflare.com/docs", + "requiredEnvVars": ["SALESFLARE_API_KEY"], + "connector": { + "name": "Salesflare API v1", + "type": "REST", + "baseUrl": "https://api.salesflare.com", + "authType": "API_KEY", + "authConfig": { + "headerName": "Authorization", + "apiKey": "{{SALESFLARE_API_KEY}}" + } + }, + "tools": [ + { + "name": "salesflare_list_contacts", + "description": "List contacts.", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Max 200." }, + "offset": { "type": "integer", "description": "0-based." }, + "search": { "type": "string", "description": "Free-text search." }, + "account_id": { "type": "integer", "description": "Filter by account." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/contacts", + "queryParams": { + "limit": "$limit", + "offset": "$offset", + "search": "$search", + "account_id": "$account_id" + } + } + }, + { + "name": "salesflare_get_contact", + "description": "Get one contact with full custom fields + accounts.", + "parameters": { + "type": "object", + "properties": { + "contact_id": { "type": "integer", "description": "Contact ID." } + }, + "required": ["contact_id"] + }, + "endpointMapping": { "method": "GET", "path": "/contacts/{contact_id}" } + }, + { + "name": "salesflare_create_contact", + "description": "Create a contact.", + "parameters": { + "type": "object", + "properties": { + "firstname": { "type": "string", "description": "First name." }, + "lastname": { "type": "string", "description": "Last name." }, + "email": { "type": "string", "description": "Primary email." }, + "phone_numbers": { "type": "array", "description": "Array of {type, number} objects." }, + "accounts": { "type": "array", "description": "Array of {id} account refs to link." }, + "tags": { "type": "array", "description": "Array of tag name strings." }, + "custom": { "type": "object", "description": "Custom field map." } + }, + "required": ["email"] + }, + "endpointMapping": { + "method": "POST", + "path": "/contacts", + "bodyMapping": { + "firstname": "$firstname", + "lastname": "$lastname", + "email": "$email", + "phone_numbers": "$phone_numbers", + "accounts": "$accounts", + "tags": "$tags", + "custom": "$custom" + } + } + }, + { + "name": "salesflare_list_accounts", + "description": "List accounts (companies).", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Max 200." }, + "offset": { "type": "integer", "description": "0-based." }, + "search": { "type": "string", "description": "Name search." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/accounts", + "queryParams": { "limit": "$limit", "offset": "$offset", "search": "$search" } + } + }, + { + "name": "salesflare_create_account", + "description": "Create an account (company).", + "parameters": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Company name." }, + "website": { "type": "string", "description": "Website." }, + "phone_number": { "type": "string", "description": "Phone." }, + "addresses": { "type": "array", "description": "Array of address objects." }, + "tags": { "type": "array", "description": "Array of tag names." }, + "industry_id": { "type": "integer", "description": "Industry ID." } + }, + "required": ["name"] + }, + "endpointMapping": { + "method": "POST", + "path": "/accounts", + "bodyMapping": { + "name": "$name", + "website": "$website", + "phone_number": "$phone_number", + "addresses": "$addresses", + "tags": "$tags", + "industry_id": "$industry_id" + } + } + }, + { + "name": "salesflare_list_opportunities", + "description": "List opportunities (deals).", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Max 200." }, + "offset": { "type": "integer", "description": "0-based." }, + "stage_id": { "type": "integer", "description": "Filter by stage." }, + "account_id": { "type": "integer", "description": "Filter by account." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/opportunities", + "queryParams": { + "limit": "$limit", + "offset": "$offset", + "stage_id": "$stage_id", + "account_id": "$account_id" + } + } + }, + { + "name": "salesflare_create_opportunity", + "description": "Create a deal/opportunity.", + "parameters": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Deal name." }, + "account": { "type": "object", "description": "{id} account ref." }, + "value": { "type": "number", "description": "Deal value." }, + "currency": { "type": "string", "description": "ISO 4217." }, + "probability": { "type": "integer", "description": "0-100." }, + "expected_close_date": { "type": "string", "description": "ISO 8601." }, + "stage_id": { "type": "integer", "description": "Pipeline stage ID." }, + "owner_id": { "type": "integer", "description": "Salesflare user ID." } + }, + "required": ["name"] + }, + "endpointMapping": { + "method": "POST", + "path": "/opportunities", + "bodyMapping": { + "name": "$name", + "account": "$account", + "value": "$value", + "currency": "$currency", + "probability": "$probability", + "expected_close_date": "$expected_close_date", + "stage_id": "$stage_id", + "owner_id": "$owner_id" + } + } + }, + { + "name": "salesflare_list_tasks", + "description": "List tasks. Filter by status, owner, due date.", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Max 200." }, + "offset": { "type": "integer", "description": "0-based." }, + "status": { "type": "string", "description": "open, done." }, + "owner_id": { "type": "integer", "description": "Filter by owner." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/tasks", + "queryParams": { + "limit": "$limit", + "offset": "$offset", + "status": "$status", + "owner_id": "$owner_id" + } + } + }, + { + "name": "salesflare_create_task", + "description": "Create a task.", + "parameters": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Task title." }, + "description": { "type": "string", "description": "Description." }, + "remind_date": { "type": "string", "description": "ISO 8601 due date." }, + "owner_id": { "type": "integer", "description": "Assignee user ID." }, + "contact_id": { "type": "integer", "description": "Linked contact." }, + "account_id": { "type": "integer", "description": "Linked account." }, + "opportunity_id": { "type": "integer", "description": "Linked opportunity." } + }, + "required": ["name"] + }, + "endpointMapping": { + "method": "POST", + "path": "/tasks", + "bodyMapping": { + "name": "$name", + "description": "$description", + "remind_date": "$remind_date", + "owner_id": "$owner_id", + "contact_id": "$contact_id", + "account_id": "$account_id", + "opportunity_id": "$opportunity_id" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/salesflare.live.spec.ts b/packages/backend/src/adapters/intl/salesflare.live.spec.ts new file mode 100644 index 0000000..4233226 --- /dev/null +++ b/packages/backend/src/adapters/intl/salesflare.live.spec.ts @@ -0,0 +1,12 @@ +import * as adapter from './salesflare.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: { headerName: string } }; +}; +describe('salesflare adapter — static spec conformance', () => { + it('api.salesflare.com base URL', () => + expect(a.connector.baseUrl).toBe('https://api.salesflare.com')); + it('raw API key sent as Authorization header value', () => { + expect(a.connector.authType).toBe('API_KEY'); + expect(a.connector.authConfig.headerName).toBe('Authorization'); + }); +}); diff --git a/packages/backend/src/adapters/intl/streak.json b/packages/backend/src/adapters/intl/streak.json new file mode 100644 index 0000000..932c176 --- /dev/null +++ b/packages/backend/src/adapters/intl/streak.json @@ -0,0 +1,157 @@ +{ + "slug": "streak", + "name": "Streak", + "description": "Manage Streak (CRM inside Gmail) pipelines, boxes, contacts, fields from any AI agent. 9 tools, basic-auth with API key.", + "instructions": "This connector wraps the Streak REST API v1/v2 (api.streak.com/api).\n\n**Setup**:\n1. Sign in to https://www.streak.com → top-right gear → **Settings → API keys → Generate API key**.\n2. Copy the key. Set `STREAK_API_KEY`.\n\n**Authentication**: HTTP Basic — API key is the username, password is empty:\n `Authorization: Basic base64(API_KEY:)`\n\n**Streak vocabulary**:\n- **Pipeline** = a CRM workflow (e.g. 'Sales', 'Hiring').\n- **Box** = a record/deal/case inside a pipeline.\n- **Stage** = pipeline column (e.g. 'Lead', 'Qualified', 'Closed Won').\n- **Field** = custom field on a pipeline (definition) or box (value).\n\n**Two API versions**: v1 covers everything; v2 has a few newer write endpoints with cleaner shapes (see `pipelines/v2`).\n\n**IDs are URL-safe strings** (e.g. `agxzfm1haWxmb29nYWVyEAsSBE5vdGUYgIDA88-IpAoMogEFc3RyZWFr`).\n\n**Pagination**: `limit` + `sortDirection`; some endpoints use `lastId` cursor.\n\n**Rate limits**: 600 req/hour per API key; 10 burst. 429 → back off 6s.\n\n**Out of scope here**: snippets (email templates), team management, billing.", + "region": "intl", + "category": "crm", + "icon": "streak", + "docsUrl": "https://streak.readme.io/reference/", + "requiredEnvVars": ["STREAK_API_KEY"], + "connector": { + "name": "Streak API v1", + "type": "REST", + "baseUrl": "https://api.streak.com/api", + "authType": "BASIC_AUTH", + "authConfig": { + "username": "{{STREAK_API_KEY}}", + "password": "" + } + }, + "tools": [ + { + "name": "streak_current_user", + "description": "Returns the authenticated user: userKey, displayName, email, isOauth, lastSeenTimestamp.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/v1/users/me" } + }, + { + "name": "streak_list_pipelines", + "description": "List all pipelines the user has access to. Each has pipelineKey, name, description, stages[], fields[], creatorKey, teamKey.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/v1/pipelines" } + }, + { + "name": "streak_get_pipeline", + "description": "Get one pipeline with stages and field definitions.", + "parameters": { + "type": "object", + "properties": { + "pipeline_key": { "type": "string", "description": "Pipeline key." } + }, + "required": ["pipeline_key"] + }, + "endpointMapping": { "method": "GET", "path": "/v1/pipelines/{pipeline_key}" } + }, + { + "name": "streak_list_boxes", + "description": "List boxes (records) in a pipeline. Each has boxKey, name, stageKey, fields (custom field values), notes count, files count.", + "parameters": { + "type": "object", + "properties": { + "pipeline_key": { "type": "string", "description": "Pipeline key." }, + "limit": { "type": "integer", "description": "Max per page." }, + "sortBy": { "type": "string", "description": "creationTimestamp, lastUpdatedTimestamp." }, + "sortDirection": { "type": "string", "description": "ASC or DESC." } + }, + "required": ["pipeline_key"] + }, + "endpointMapping": { + "method": "GET", + "path": "/v1/pipelines/{pipeline_key}/boxes", + "queryParams": { + "limit": "$limit", + "sortBy": "$sortBy", + "sortDirection": "$sortDirection" + } + } + }, + { + "name": "streak_get_box", + "description": "Get one box with full custom-field values, assigned users, linked contacts, and Gmail thread refs.", + "parameters": { + "type": "object", + "properties": { + "box_key": { "type": "string", "description": "Box key." } + }, + "required": ["box_key"] + }, + "endpointMapping": { "method": "GET", "path": "/v1/boxes/{box_key}" } + }, + { + "name": "streak_create_box", + "description": "Create a new box in a pipeline.", + "parameters": { + "type": "object", + "properties": { + "pipeline_key": { "type": "string", "description": "Pipeline key." }, + "name": { "type": "string", "description": "Box name (deal title)." }, + "stageKey": { "type": "string", "description": "Initial stage key." }, + "assignedToSharingEntries": { "type": "array", "description": "Array of {sharedEntity} user keys to assign." } + }, + "required": ["pipeline_key", "name"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/v1/pipelines/{pipeline_key}/boxes", + "bodyMapping": { + "name": "$name", + "stageKey": "$stageKey", + "assignedToSharingEntries": "$assignedToSharingEntries" + } + } + }, + { + "name": "streak_update_box", + "description": "Update a box: rename, move stage, set custom field values.", + "parameters": { + "type": "object", + "properties": { + "box_key": { "type": "string", "description": "Box key." }, + "name": { "type": "string", "description": "New name." }, + "stageKey": { "type": "string", "description": "New stage." }, + "fields": { "type": "object", "description": "Custom-field-key → value map." } + }, + "required": ["box_key"] + }, + "endpointMapping": { + "method": "POST", + "path": "/v1/boxes/{box_key}", + "bodyMapping": { + "name": "$name", + "stageKey": "$stageKey", + "fields": "$fields" + } + } + }, + { + "name": "streak_list_box_notes", + "description": "List notes attached to a box.", + "parameters": { + "type": "object", + "properties": { + "box_key": { "type": "string", "description": "Box key." } + }, + "required": ["box_key"] + }, + "endpointMapping": { "method": "GET", "path": "/v1/boxes/{box_key}/notes" } + }, + { + "name": "streak_add_box_note", + "description": "Add a text note to a box.", + "parameters": { + "type": "object", + "properties": { + "box_key": { "type": "string", "description": "Box key." }, + "message": { "type": "string", "description": "Note body." } + }, + "required": ["box_key", "message"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/v1/boxes/{box_key}/notes", + "bodyMapping": { "message": "$message" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/streak.live.spec.ts b/packages/backend/src/adapters/intl/streak.live.spec.ts new file mode 100644 index 0000000..e98d3b0 --- /dev/null +++ b/packages/backend/src/adapters/intl/streak.live.spec.ts @@ -0,0 +1,12 @@ +import * as adapter from './streak.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: { password: string } }; +}; +describe('streak adapter — static spec conformance', () => { + it('api.streak.com/api base URL', () => + expect(a.connector.baseUrl).toBe('https://api.streak.com/api')); + it('Basic auth with empty password (API key as username)', () => { + expect(a.connector.authType).toBe('BASIC_AUTH'); + expect(a.connector.authConfig.password).toBe(''); + }); +}); diff --git a/packages/backend/src/adapters/intl/teamwork-projects.json b/packages/backend/src/adapters/intl/teamwork-projects.json new file mode 100644 index 0000000..d40a27b --- /dev/null +++ b/packages/backend/src/adapters/intl/teamwork-projects.json @@ -0,0 +1,251 @@ +{ + "slug": "teamwork-projects", + "name": "Teamwork Projects", + "description": "Manage Teamwork.com (projects, tasks, milestones, time entries, comments, people) from any AI agent. 10 tools, basic-auth with API key.", + "instructions": "This connector wraps the Teamwork Projects REST API v3 (per-subdomain — yoursubdomain.teamwork.com/projects/api/v3).\n\n**Setup**:\n1. Log into your Teamwork account → top-right avatar → **Edit my details → API & Mobile → Get my API Key**.\n2. Note your subdomain (the part before `.teamwork.com`).\n3. Set `TEAMWORK_SUBDOMAIN` and `TEAMWORK_API_KEY`.\n\n**Authentication**: HTTP Basic — API key as username, literal `x` as password.\n\n**Per-tenant baseUrl**: `https://${TEAMWORK_SUBDOMAIN}.teamwork.com/projects/api/v3`. Substituted at import time.\n\n**API versions**: v3 is the modern REST. v1/v2 still exist for some endpoints. This adapter uses v3 where available.\n\n**Pagination**: `page` (1-based) + `pageSize` (max 250). Some endpoints use `pageOffset`.\n\n**Rate limits**: 150 req/min per token. 429 with `Retry-After`.\n\n**Out of scope here**: Spaces (separate product API), CRM, Desk (helpdesk), Chat — each is a sibling product with its own API.", + "region": "intl", + "category": "project-management", + "icon": "teamwork-projects", + "docsUrl": "https://apidocs.teamwork.com/", + "requiredEnvVars": ["TEAMWORK_SUBDOMAIN", "TEAMWORK_API_KEY"], + "connector": { + "name": "Teamwork Projects API v3", + "type": "REST", + "baseUrl": "https://{{TEAMWORK_SUBDOMAIN}}.teamwork.com/projects/api/v3", + "authType": "BASIC_AUTH", + "authConfig": { + "username": "{{TEAMWORK_API_KEY}}", + "password": "x" + } + }, + "tools": [ + { + "name": "teamwork_list_projects", + "description": "List projects. Filter by status (active, archived, current, late, upcoming, completed).", + "parameters": { + "type": "object", + "properties": { + "status": { "type": "string", "description": "active, archived, current, late, upcoming, completed, all." }, + "searchTerm": { "type": "string", "description": "Name search." }, + "page": { "type": "integer", "description": "1-based." }, + "pageSize": { "type": "integer", "description": "Max 250." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/projects.json", + "queryParams": { + "status": "$status", + "searchTerm": "$searchTerm", + "page": "$page", + "pageSize": "$pageSize" + } + } + }, + { + "name": "teamwork_get_project", + "description": "Get one project with summary metrics.", + "parameters": { + "type": "object", + "properties": { + "project_id": { "type": "integer", "description": "Project ID." } + }, + "required": ["project_id"] + }, + "endpointMapping": { "method": "GET", "path": "/projects/{project_id}.json" } + }, + { + "name": "teamwork_create_project", + "description": "Create a project.", + "parameters": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Project name." }, + "description": { "type": "string", "description": "Description." }, + "companyId": { "type": "integer", "description": "Owning company ID." }, + "categoryId": { "type": "integer", "description": "Category ID." }, + "startAt": { "type": "string", "description": "YYYYMMDD." }, + "endAt": { "type": "string", "description": "YYYYMMDD." } + }, + "required": ["name"] + }, + "endpointMapping": { + "method": "POST", + "path": "/projects.json", + "bodyMapping": { + "project": { + "name": "$name", + "description": "$description", + "companyId": "$companyId", + "categoryId": "$categoryId", + "startAt": "$startAt", + "endAt": "$endAt" + } + } + } + }, + { + "name": "teamwork_list_tasks", + "description": "List tasks across all projects (or one). Filter by assignee, status, due date.", + "parameters": { + "type": "object", + "properties": { + "project_id": { "type": "integer", "description": "Filter to one project." }, + "assignedTo": { "type": "string", "description": "Comma-separated user IDs." }, + "status": { "type": "string", "description": "completed, incomplete, all." }, + "dueDateFrom": { "type": "string", "description": "YYYYMMDD." }, + "dueDateTo": { "type": "string", "description": "YYYYMMDD." }, + "page": { "type": "integer", "description": "1-based." }, + "pageSize": { "type": "integer", "description": "Max 250." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/tasks.json", + "queryParams": { + "projectIds": "$project_id", + "assignedTo": "$assignedTo", + "status": "$status", + "dueDateFrom": "$dueDateFrom", + "dueDateTo": "$dueDateTo", + "page": "$page", + "pageSize": "$pageSize" + } + } + }, + { + "name": "teamwork_get_task", + "description": "Get one task with all custom fields, time logs, dependencies.", + "parameters": { + "type": "object", + "properties": { + "task_id": { "type": "integer", "description": "Task ID." } + }, + "required": ["task_id"] + }, + "endpointMapping": { "method": "GET", "path": "/tasks/{task_id}.json" } + }, + { + "name": "teamwork_create_task", + "description": "Create a task inside a task list.", + "parameters": { + "type": "object", + "properties": { + "tasklist_id": { "type": "integer", "description": "Tasklist ID." }, + "content": { "type": "string", "description": "Task title." }, + "description": { "type": "string", "description": "Description (HTML)." }, + "responsiblePartyId": { "type": "string", "description": "User ID(s), comma-separated for multi." }, + "priority": { "type": "string", "description": "low, medium, high." }, + "startDate": { "type": "string", "description": "YYYYMMDD." }, + "dueDate": { "type": "string", "description": "YYYYMMDD." }, + "estimatedMinutes": { "type": "integer", "description": "Estimated effort." } + }, + "required": ["tasklist_id", "content"] + }, + "endpointMapping": { + "method": "POST", + "path": "/tasklists/{tasklist_id}/tasks.json", + "bodyMapping": { + "todo-item": { + "content": "$content", + "description": "$description", + "responsible-party-id": "$responsiblePartyId", + "priority": "$priority", + "start-date": "$startDate", + "due-date": "$dueDate", + "estimated-minutes": "$estimatedMinutes" + } + } + } + }, + { + "name": "teamwork_complete_task", + "description": "Mark a task complete.", + "parameters": { + "type": "object", + "properties": { + "task_id": { "type": "integer", "description": "Task ID." } + }, + "required": ["task_id"] + }, + "endpointMapping": { "method": "PUT", "path": "/tasks/{task_id}/complete.json" } + }, + { + "name": "teamwork_log_time", + "description": "Log time on a task or project.", + "parameters": { + "type": "object", + "properties": { + "task_id": { "type": "integer", "description": "Task ID (or use project_id)." }, + "project_id": { "type": "integer", "description": "Project ID if no task." }, + "person_id": { "type": "integer", "description": "User logging time." }, + "date": { "type": "string", "description": "YYYYMMDD." }, + "hours": { "type": "integer", "description": "Whole hours." }, + "minutes": { "type": "integer", "description": "Whole minutes." }, + "description": { "type": "string", "description": "Notes." }, + "isbillable": { "type": "boolean", "description": "Billable." } + }, + "required": ["person_id", "date", "hours"] + }, + "endpointMapping": { + "method": "POST", + "path": "/projects/{project_id}/time_entries.json", + "bodyMapping": { + "time-entry": { + "person-id": "$person_id", + "date": "$date", + "hours": "$hours", + "minutes": "$minutes", + "description": "$description", + "isbillable": "$isbillable", + "task-id": "$task_id" + } + } + } + }, + { + "name": "teamwork_list_milestones", + "description": "List milestones across projects.", + "parameters": { + "type": "object", + "properties": { + "project_id": { "type": "integer", "description": "Filter to one project." }, + "find": { "type": "string", "description": "all, upcoming, late, completed." }, + "page": { "type": "integer", "description": "1-based." }, + "pageSize": { "type": "integer", "description": "Max 250." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/milestones.json", + "queryParams": { + "projectIds": "$project_id", + "find": "$find", + "page": "$page", + "pageSize": "$pageSize" + } + } + }, + { + "name": "teamwork_list_people", + "description": "List users (people).", + "parameters": { + "type": "object", + "properties": { + "page": { "type": "integer", "description": "1-based." }, + "pageSize": { "type": "integer", "description": "Max 250." }, + "showDeleted": { "type": "boolean", "description": "Include deleted users." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/people.json", + "queryParams": { + "page": "$page", + "pageSize": "$pageSize", + "showDeleted": "$showDeleted" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/teamwork-projects.live.spec.ts b/packages/backend/src/adapters/intl/teamwork-projects.live.spec.ts new file mode 100644 index 0000000..e6699a2 --- /dev/null +++ b/packages/backend/src/adapters/intl/teamwork-projects.live.spec.ts @@ -0,0 +1,12 @@ +import * as adapter from './teamwork-projects.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: { password: string } }; +}; +describe('teamwork-projects adapter — static spec conformance', () => { + it('per-tenant baseUrl with subdomain placeholder', () => + expect(a.connector.baseUrl).toBe('https://{{TEAMWORK_SUBDOMAIN}}.teamwork.com/projects/api/v3')); + it('Basic auth with literal password "x"', () => { + expect(a.connector.authType).toBe('BASIC_AUTH'); + expect(a.connector.authConfig.password).toBe('x'); + }); +}); diff --git a/packages/backend/src/adapters/intl/tidio.json b/packages/backend/src/adapters/intl/tidio.json new file mode 100644 index 0000000..54019d8 --- /dev/null +++ b/packages/backend/src/adapters/intl/tidio.json @@ -0,0 +1,151 @@ +{ + "slug": "tidio", + "name": "Tidio", + "description": "Manage Tidio (live chat + AI chatbot) contacts, conversations, messages, tags via GraphQL from any AI agent. 7 tools, X-Tidio-Openapi-Token auth.", + "instructions": "This connector wraps the Tidio Public API (api.tidio.co/v1/graphql).\n\n**Setup**:\n1. Log into https://www.tidio.com → **Settings → Public API** (Tidio+ plan only).\n2. Generate an API token. Set `TIDIO_API_TOKEN`.\n\n**Authentication**: custom header `X-Tidio-Openapi-Token: ${TIDIO_API_TOKEN}` on every request.\n\n**GraphQL-only**: Tidio's Public API is GraphQL — the adapter exposes a few common queries/mutations as wrappers; for ad-hoc queries use the auto-injected `tidio_graphql_query` / `tidio_graphql_mutation` / `tidio_graphql_schema` builtins.\n\n**Visitor vs Contact**: Tidio tracks anonymous visitors (no email) and contacts (have email/identifier). Bind a visitor to a contact via `identify`.\n\n**Pagination**: cursor-based via Relay edges: `edges[].node`, `pageInfo {endCursor, hasNextPage}`.\n\n**Rate limits**: 10 req/sec per token + complexity budget. 429 with `Retry-After`.\n\n**Out of scope here**: chatbot flow editor, broadcast scheduling, A/B testing — these are UI-only.", + "region": "intl", + "category": "support", + "icon": "tidio", + "docsUrl": "https://docs.tidio.com/public-api/", + "requiredEnvVars": ["TIDIO_API_TOKEN"], + "connector": { + "name": "Tidio GraphQL API v1", + "type": "GRAPHQL", + "baseUrl": "https://api.tidio.co/v1/graphql", + "authType": "API_KEY", + "authConfig": { + "headerName": "X-Tidio-Openapi-Token", + "apiKey": "{{TIDIO_API_TOKEN}}" + } + }, + "tools": [ + { + "name": "tidio_get_project", + "description": "Get info about the connected Tidio project (id, name, plan).", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { + "method": "query", + "path": "{ project { id name plan } }" + } + }, + { + "name": "tidio_list_contacts", + "description": "List contacts.", + "parameters": { + "type": "object", + "properties": { + "first": { "type": "integer", "description": "Page size." }, + "after": { "type": "string", "description": "Cursor." } + } + }, + "endpointMapping": { + "method": "query", + "path": "query ListContacts($first: Int, $after: String) { contacts(first: $first, after: $after) { edges { node { id name email phone tags { name } } } pageInfo { endCursor hasNextPage } } }", + "bodyMapping": { "variables": { "first": "$first", "after": "$after" } } + } + }, + { + "name": "tidio_get_contact_by_email", + "description": "Look up a contact by email.", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "Email." } + }, + "required": ["email"] + }, + "endpointMapping": { + "method": "query", + "path": "query GetContact($email: String!) { contact(email: $email) { id name email phone tags { name } customAttributes { key value } } }", + "bodyMapping": { "variables": { "email": "$email" } } + } + }, + { + "name": "tidio_update_contact", + "description": "Update / upsert a contact by email.", + "parameters": { + "type": "object", + "properties": { + "email": { "type": "string", "description": "Email (identifier)." }, + "name": { "type": "string", "description": "New name." }, + "phone": { "type": "string", "description": "Phone." }, + "tags": { "type": "array", "description": "Tag name strings." } + }, + "required": ["email"] + }, + "endpointMapping": { + "method": "mutation", + "path": "mutation UpdateContact($input: UpdateContactInput!) { contactUpdate(input: $input) { contact { id email } } }", + "bodyMapping": { + "variables": { + "input": { + "email": "$email", + "name": "$name", + "phone": "$phone", + "tags": "$tags" + } + } + } + } + }, + { + "name": "tidio_list_conversations", + "description": "List recent conversations.", + "parameters": { + "type": "object", + "properties": { + "first": { "type": "integer", "description": "Page size." }, + "after": { "type": "string", "description": "Cursor." }, + "status": { "type": "string", "description": "OPEN, SOLVED, PENDING." } + } + }, + "endpointMapping": { + "method": "query", + "path": "query ListConvs($first: Int, $after: String, $status: ConversationStatus) { conversations(first: $first, after: $after, status: $status) { edges { node { id status contact { id email } assignee { id name } lastMessageAt } } pageInfo { endCursor hasNextPage } } }", + "bodyMapping": { + "variables": { "first": "$first", "after": "$after", "status": "$status" } + } + } + }, + { + "name": "tidio_get_conversation", + "description": "Get a single conversation with messages.", + "parameters": { + "type": "object", + "properties": { + "id": { "type": "string", "description": "Conversation ID." } + }, + "required": ["id"] + }, + "endpointMapping": { + "method": "query", + "path": "query GetConv($id: ID!) { conversation(id: $id) { id status contact { id email } messages(first: 50) { edges { node { id content author { id name role } createdAt } } } } }", + "bodyMapping": { "variables": { "id": "$id" } } + } + }, + { + "name": "tidio_send_message", + "description": "Send a message to a conversation as an operator.", + "parameters": { + "type": "object", + "properties": { + "conversation_id": { "type": "string", "description": "Conversation ID." }, + "content": { "type": "string", "description": "Message text." } + }, + "required": ["conversation_id", "content"] + }, + "endpointMapping": { + "method": "mutation", + "path": "mutation Send($input: SendMessageInput!) { messageSend(input: $input) { message { id } } }", + "bodyMapping": { + "variables": { + "input": { + "conversationId": "$conversation_id", + "content": "$content" + } + } + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/tidio.live.spec.ts b/packages/backend/src/adapters/intl/tidio.live.spec.ts new file mode 100644 index 0000000..a5941a8 --- /dev/null +++ b/packages/backend/src/adapters/intl/tidio.live.spec.ts @@ -0,0 +1,12 @@ +import * as adapter from './tidio.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; type: string; authConfig: { headerName: string } }; +}; +describe('tidio adapter — static spec conformance', () => { + it('api.tidio.co/v1/graphql base URL', () => + expect(a.connector.baseUrl).toBe('https://api.tidio.co/v1/graphql')); + it('GraphQL connector with custom header auth', () => { + expect(a.connector.type).toBe('GRAPHQL'); + expect(a.connector.authConfig.headerName).toBe('X-Tidio-Openapi-Token'); + }); +}); diff --git a/packages/backend/src/adapters/intl/timetastic.json b/packages/backend/src/adapters/intl/timetastic.json new file mode 100644 index 0000000..d1578df --- /dev/null +++ b/packages/backend/src/adapters/intl/timetastic.json @@ -0,0 +1,162 @@ +{ + "slug": "timetastic", + "name": "Timetastic", + "description": "Manage Timetastic (PTO / annual leave tracking) users, holidays, departments, leave types from any AI agent. 8 tools, Bearer token.", + "instructions": "This connector wraps the Timetastic REST API v2 (app.timetastic.co.uk/api).\n\n**Setup**:\n1. Log into https://app.timetastic.co.uk → top-right avatar → **Settings → Integrations → API → Generate API token**.\n2. Set `TIMETASTIC_API_TOKEN`.\n\n**Authentication**: `Authorization: Bearer ${TIMETASTIC_API_TOKEN}`.\n\n**Entities**: `User` (employees) → `Holiday` (leave requests). Each holiday has start/end, type (annual, sickness, etc.), status (approved/declined/pending).\n\n**Pagination**: most endpoints return everything (paginated only by `Start`/`End` filters). Use date windows aggressively.\n\n**Rate limits**: 60 req/min per token. 429 with `Retry-After`.\n\n**Out of scope here**: WallChart UI, custom reports, multi-tenant org switching.", + "region": "intl", + "category": "time-tracking", + "icon": "timetastic", + "docsUrl": "https://timetastic.docs.apiary.io/", + "requiredEnvVars": ["TIMETASTIC_API_TOKEN"], + "connector": { + "name": "Timetastic API v2", + "type": "REST", + "baseUrl": "https://app.timetastic.co.uk/api", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{TIMETASTIC_API_TOKEN}}" + } + }, + "tools": [ + { + "name": "timetastic_list_users", + "description": "List all users (employees) in the account.", + "parameters": { + "type": "object", + "properties": { + "active_only": { "type": "boolean", "description": "If true, exclude archived users." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/users", + "queryParams": { "active": "$active_only" } + } + }, + { + "name": "timetastic_get_user", + "description": "Get one user with leave allowance, department, manager.", + "parameters": { + "type": "object", + "properties": { + "user_id": { "type": "integer", "description": "User ID." } + }, + "required": ["user_id"] + }, + "endpointMapping": { "method": "GET", "path": "/users/{user_id}" } + }, + { + "name": "timetastic_create_user", + "description": "Create an employee.", + "parameters": { + "type": "object", + "properties": { + "firstname": { "type": "string", "description": "First name." }, + "surname": { "type": "string", "description": "Surname." }, + "email": { "type": "string", "description": "Email." }, + "department_id": { "type": "integer", "description": "Department ID." }, + "annual_allowance": { "type": "number", "description": "Days of leave per year." }, + "start_date": { "type": "string", "description": "Employment start (YYYY-MM-DD)." } + }, + "required": ["firstname", "surname", "email"] + }, + "endpointMapping": { + "method": "POST", + "path": "/users", + "bodyMapping": { + "firstname": "$firstname", + "surname": "$surname", + "email": "$email", + "departmentId": "$department_id", + "annualAllowance": "$annual_allowance", + "startDate": "$start_date" + } + } + }, + { + "name": "timetastic_list_holidays", + "description": "List holiday (leave) requests. Filter by date range, status, user, department.", + "parameters": { + "type": "object", + "properties": { + "start": { "type": "string", "description": "YYYY-MM-DD." }, + "end": { "type": "string", "description": "YYYY-MM-DD." }, + "user_id": { "type": "integer", "description": "Filter by user." }, + "department_id": { "type": "integer", "description": "Filter by department." }, + "status": { "type": "string", "description": "Approved, Declined, Pending, Cancelled." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/holidays", + "queryParams": { + "Start": "$start", + "End": "$end", + "userId": "$user_id", + "departmentId": "$department_id", + "status": "$status" + } + } + }, + { + "name": "timetastic_create_holiday", + "description": "Create a leave request.", + "parameters": { + "type": "object", + "properties": { + "user_id": { "type": "integer", "description": "User ID." }, + "start_date": { "type": "string", "description": "YYYY-MM-DD." }, + "start_type": { "type": "string", "description": "Morning, Afternoon, Fullday." }, + "end_date": { "type": "string", "description": "YYYY-MM-DD." }, + "end_type": { "type": "string", "description": "Morning, Afternoon, Fullday." }, + "leave_type": { "type": "string", "description": "Annual leave, Sickness, ... (must match a configured type)." }, + "reason": { "type": "string", "description": "Free-text." } + }, + "required": ["user_id", "start_date", "end_date"] + }, + "endpointMapping": { + "method": "POST", + "path": "/holidays", + "bodyMapping": { + "userId": "$user_id", + "startDate": "$start_date", + "startType": "$start_type", + "endDate": "$end_date", + "endType": "$end_type", + "leaveType": "$leave_type", + "reason": "$reason" + } + } + }, + { + "name": "timetastic_decide_holiday", + "description": "Approve or decline a pending holiday request.", + "parameters": { + "type": "object", + "properties": { + "holiday_id": { "type": "integer", "description": "Holiday request ID." }, + "decision": { "type": "string", "description": "Approved or Declined." }, + "reason": { "type": "string", "description": "Optional decline reason." } + }, + "required": ["holiday_id", "decision"] + }, + "endpointMapping": { + "method": "POST", + "path": "/holidays/{holiday_id}/decision", + "bodyMapping": { "decision": "$decision", "reason": "$reason" } + } + }, + { + "name": "timetastic_list_departments", + "description": "List departments.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/departments" } + }, + { + "name": "timetastic_list_leave_types", + "description": "List configured leave types (Annual leave, Sickness, etc.).", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/leavetypes" } + } + ] +} diff --git a/packages/backend/src/adapters/intl/timetastic.live.spec.ts b/packages/backend/src/adapters/intl/timetastic.live.spec.ts new file mode 100644 index 0000000..bb0cf65 --- /dev/null +++ b/packages/backend/src/adapters/intl/timetastic.live.spec.ts @@ -0,0 +1,7 @@ +import * as adapter from './timetastic.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string } }; +describe('timetastic adapter — static spec conformance', () => { + it('app.timetastic.co.uk/api base URL', () => + expect(a.connector.baseUrl).toBe('https://app.timetastic.co.uk/api')); + it('Bearer auth', () => expect(a.connector.authType).toBe('BEARER_TOKEN')); +}); diff --git a/packages/backend/src/adapters/intl/wave-accounting.json b/packages/backend/src/adapters/intl/wave-accounting.json new file mode 100644 index 0000000..470a4dd --- /dev/null +++ b/packages/backend/src/adapters/intl/wave-accounting.json @@ -0,0 +1,185 @@ +{ + "slug": "wave-accounting", + "name": "Wave Accounting", + "description": "Query Wave Accounting (customers, products, invoices, accounts, transactions) via GraphQL from any AI agent. 8 tools, Bearer token auth.", + "instructions": "This connector wraps the Wave GraphQL API (gql.waveapps.com/graphql/public).\n\n**Setup**:\n1. Sign in to https://my.waveapps.com → bottom-left avatar → **Manage Applications → Create Application**.\n2. Use the OAuth flow to obtain an access token, OR (faster) generate a **Full Access Token** from the application settings (single-tenant).\n3. **Business ID**: Wave is multi-business — find your business ID via the `wave_get_businesses` query or in the URL of any Wave page.\n4. Set `WAVE_ACCESS_TOKEN` and `WAVE_BUSINESS_ID`.\n\n**Authentication**: `Authorization: Bearer ${WAVE_ACCESS_TOKEN}`.\n\n**GraphQL-only**: Wave has no REST. The auto-injected GraphQL builtins (`wave_accounting_graphql_query`, `wave_accounting_graphql_mutation`, `wave_accounting_graphql_schema`) are available for arbitrary queries. The typed wrappers below cover the most common cases.\n\n**Cursor pagination**: edges + pageInfo {endCursor, hasNextPage}. Pass `after: \"\"` to paginate.\n\n**Money** is `{value, currency}` objects with `value` as a string in minor units (cents).\n\n**Rate limits**: 60 req/min per token + complexity budget. 429 with `Retry-After`.\n\n**Out of scope here**: payroll, payment-processing setup, bank-feed reconciliation UI.", + "region": "intl", + "category": "accounting", + "icon": "wave-accounting", + "docsUrl": "https://developer.waveapps.com/hc/en-us/articles/360019968212", + "requiredEnvVars": ["WAVE_ACCESS_TOKEN", "WAVE_BUSINESS_ID"], + "connector": { + "name": "Wave Accounting GraphQL", + "type": "GRAPHQL", + "baseUrl": "https://gql.waveapps.com/graphql/public", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{WAVE_ACCESS_TOKEN}}" + } + }, + "tools": [ + { + "name": "wave_get_businesses", + "description": "List all businesses the user can access — returns id, name, isClassicAccounting, currency.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { + "method": "query", + "path": "{ user { id defaultEmail businesses(page: 1, pageSize: 50) { edges { node { id name isClassicAccounting currency { code } } } } } }" + } + }, + { + "name": "wave_list_customers", + "description": "List customers in a business.", + "parameters": { + "type": "object", + "properties": { + "business_id": { "type": "string", "description": "Wave business ID." }, + "page": { "type": "integer", "description": "1-based." }, + "page_size": { "type": "integer", "description": "Max 50." } + }, + "required": ["business_id"] + }, + "endpointMapping": { + "method": "query", + "path": "query ListCustomers($bid: ID!, $page: Int, $size: Int) { business(id: $bid) { customers(page: $page, pageSize: $size) { edges { node { id name email firstName lastName currency { code } } } pageInfo { totalCount } } } }", + "bodyMapping": { + "variables": { "bid": "$business_id", "page": "$page", "size": "$page_size" } + } + } + }, + { + "name": "wave_create_customer", + "description": "Create a customer.", + "parameters": { + "type": "object", + "properties": { + "business_id": { "type": "string", "description": "Wave business ID." }, + "name": { "type": "string", "description": "Customer name." }, + "email": { "type": "string", "description": "Email." }, + "currency": { "type": "string", "description": "ISO 4217." } + }, + "required": ["business_id", "name", "currency"] + }, + "endpointMapping": { + "method": "mutation", + "path": "mutation CreateCustomer($input: CustomerCreateInput!) { customerCreate(input: $input) { customer { id name } didSucceed inputErrors { path message } } }", + "bodyMapping": { + "variables": { + "input": { + "businessId": "$business_id", + "name": "$name", + "email": "$email", + "currency": "$currency" + } + } + } + } + }, + { + "name": "wave_list_invoices", + "description": "List invoices in a business. Filter by status (DRAFT, UNSENT, SAVED, SENT, VIEWED, PARTIAL, OVERDUE, PAID, OVERPAID).", + "parameters": { + "type": "object", + "properties": { + "business_id": { "type": "string", "description": "Business ID." }, + "status": { "type": "string", "description": "Invoice status." }, + "page": { "type": "integer", "description": "1-based." }, + "page_size": { "type": "integer", "description": "Max 50." } + }, + "required": ["business_id"] + }, + "endpointMapping": { + "method": "query", + "path": "query ListInvoices($bid: ID!, $status: InvoiceStatus, $page: Int, $size: Int) { business(id: $bid) { invoices(status: $status, page: $page, pageSize: $size) { edges { node { id invoiceNumber status customer { id name } invoiceDate dueDate total { value currency { code } } } } pageInfo { totalCount } } } }", + "bodyMapping": { + "variables": { + "bid": "$business_id", + "status": "$status", + "page": "$page", + "size": "$page_size" + } + } + } + }, + { + "name": "wave_get_invoice", + "description": "Get a single invoice with line items + memos.", + "parameters": { + "type": "object", + "properties": { + "invoice_id": { "type": "string", "description": "Invoice ID." } + }, + "required": ["invoice_id"] + }, + "endpointMapping": { + "method": "query", + "path": "query GetInvoice($id: ID!) { invoice(id: $id) { id invoiceNumber status customer { id name } total { value currency { code } } items { description quantity unitPrice subtotal { value } } memo footer dueDate invoiceDate } }", + "bodyMapping": { "variables": { "id": "$invoice_id" } } + } + }, + { + "name": "wave_list_products", + "description": "List products & services in a business.", + "parameters": { + "type": "object", + "properties": { + "business_id": { "type": "string", "description": "Business ID." }, + "page": { "type": "integer", "description": "1-based." }, + "page_size": { "type": "integer", "description": "Max 50." } + }, + "required": ["business_id"] + }, + "endpointMapping": { + "method": "query", + "path": "query ListProducts($bid: ID!, $page: Int, $size: Int) { business(id: $bid) { products(page: $page, pageSize: $size) { edges { node { id name description unitPrice isSold isBought } } } } }", + "bodyMapping": { + "variables": { "bid": "$business_id", "page": "$page", "size": "$page_size" } + } + } + }, + { + "name": "wave_list_accounts", + "description": "List chart-of-accounts entries (ledger accounts) for a business.", + "parameters": { + "type": "object", + "properties": { + "business_id": { "type": "string", "description": "Business ID." } + }, + "required": ["business_id"] + }, + "endpointMapping": { + "method": "query", + "path": "query ListAccounts($bid: ID!) { business(id: $bid) { accounts(page: 1, pageSize: 200) { edges { node { id name type { value name } currency { code } } } } } }", + "bodyMapping": { "variables": { "bid": "$business_id" } } + } + }, + { + "name": "wave_send_invoice", + "description": "Send (email) an invoice to its customer.", + "parameters": { + "type": "object", + "properties": { + "invoice_id": { "type": "string", "description": "Invoice ID." }, + "to": { "type": "array", "description": "Array of email addresses." }, + "subject": { "type": "string", "description": "Email subject." }, + "message": { "type": "string", "description": "Email body." } + }, + "required": ["invoice_id", "to"] + }, + "endpointMapping": { + "method": "mutation", + "path": "mutation SendInvoice($input: InvoiceSendInput!) { invoiceSend(input: $input) { didSucceed inputErrors { path message } } }", + "bodyMapping": { + "variables": { + "input": { + "invoiceId": "$invoice_id", + "to": "$to", + "subject": "$subject", + "message": "$message" + } + } + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/wave-accounting.live.spec.ts b/packages/backend/src/adapters/intl/wave-accounting.live.spec.ts new file mode 100644 index 0000000..c786971 --- /dev/null +++ b/packages/backend/src/adapters/intl/wave-accounting.live.spec.ts @@ -0,0 +1,12 @@ +import * as adapter from './wave-accounting.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; type: string; authType: string }; +}; +describe('wave-accounting adapter — static spec conformance', () => { + it('gql.waveapps.com/graphql/public base URL', () => + expect(a.connector.baseUrl).toBe('https://gql.waveapps.com/graphql/public')); + it('GraphQL connector with Bearer auth', () => { + expect(a.connector.type).toBe('GRAPHQL'); + expect(a.connector.authType).toBe('BEARER_TOKEN'); + }); +}); diff --git a/packages/backend/src/adapters/intl/workable.json b/packages/backend/src/adapters/intl/workable.json new file mode 100644 index 0000000..14f395a --- /dev/null +++ b/packages/backend/src/adapters/intl/workable.json @@ -0,0 +1,165 @@ +{ + "slug": "workable", + "name": "Workable", + "description": "Manage Workable ATS (candidates, jobs, members, stages, comments) from any AI agent. 9 tools, Bearer token auth.", + "instructions": "This connector wraps the Workable Hiring API (per-subdomain — yoursub.workable.com/spi/v3).\n\n**Setup**:\n1. Log into your Workable account → top-right avatar → **Integrations → Workable API → Generate access token**.\n2. Note your subdomain (the part before `.workable.com`).\n3. Set `WORKABLE_SUBDOMAIN` and `WORKABLE_ACCESS_TOKEN`.\n\n**Authentication**: `Authorization: Bearer ${WORKABLE_ACCESS_TOKEN}`.\n\n**Per-tenant baseUrl**: `https://${WORKABLE_SUBDOMAIN}.workable.com/spi/v3`. Substituted at import time.\n\n**Job 'shortcode'**: Workable identifies jobs by short hash strings (`AC123ABCDE`) in URLs/API. Use `workable_list_jobs` to discover.\n\n**Candidate disqualification**: writes use a stage `disqualified` and a `disqualification_reason_id`. Reasons are configured per-account.\n\n**Pagination**: cursor-based — response has `paging.next` (full URL). Pass back the URL's `since_id` or use the full cursor query.\n\n**Rate limits**: 300 req/min per account. 429 → exponential.\n\n**Out of scope here**: assessments, video interviews, requisitions, payroll fields (Workable HR Cloud is separate).", + "region": "intl", + "category": "hr", + "icon": "workable", + "docsUrl": "https://workable.readme.io/reference/", + "requiredEnvVars": ["WORKABLE_SUBDOMAIN", "WORKABLE_ACCESS_TOKEN"], + "connector": { + "name": "Workable Hiring API v3", + "type": "REST", + "baseUrl": "https://{{WORKABLE_SUBDOMAIN}}.workable.com/spi/v3", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{WORKABLE_ACCESS_TOKEN}}" + } + }, + "tools": [ + { + "name": "workable_account_info", + "description": "Returns the account info (subdomain, name, summary).", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/" } + }, + { + "name": "workable_list_jobs", + "description": "List jobs. Filter by state (published, draft, closed, archived).", + "parameters": { + "type": "object", + "properties": { + "state": { "type": "string", "description": "published, draft, closed, archived." }, + "department": { "type": "string", "description": "Filter by department." }, + "include_fields": { "type": "string", "description": "Comma-separated extras: description, requirements, benefits." }, + "limit": { "type": "integer", "description": "Max per page (100 default)." }, + "since_id": { "type": "string", "description": "Cursor — only newer than this job shortcode." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/jobs", + "queryParams": { + "state": "$state", + "department": "$department", + "include_fields": "$include_fields", + "limit": "$limit", + "since_id": "$since_id" + } + } + }, + { + "name": "workable_get_job", + "description": "Get a single job by shortcode with full description + requirements + benefits.", + "parameters": { + "type": "object", + "properties": { + "shortcode": { "type": "string", "description": "Job shortcode (e.g. AC123ABCDE)." } + }, + "required": ["shortcode"] + }, + "endpointMapping": { "method": "GET", "path": "/jobs/{shortcode}" } + }, + { + "name": "workable_list_candidates", + "description": "List candidates for a job. Filter by stage, state, created date.", + "parameters": { + "type": "object", + "properties": { + "shortcode": { "type": "string", "description": "Job shortcode." }, + "stage": { "type": "string", "description": "Stage slug to filter (e.g. 'interview')." }, + "state": { "type": "string", "description": "active, hired, disqualified." }, + "limit": { "type": "integer", "description": "Max per page." }, + "since_id": { "type": "string", "description": "Cursor." } + }, + "required": ["shortcode"] + }, + "endpointMapping": { + "method": "GET", + "path": "/jobs/{shortcode}/candidates", + "queryParams": { + "stage": "$stage", + "state": "$state", + "limit": "$limit", + "since_id": "$since_id" + } + } + }, + { + "name": "workable_get_candidate", + "description": "Get one candidate by ID with full profile + answers to job questions.", + "parameters": { + "type": "object", + "properties": { + "candidate_id": { "type": "string", "description": "Candidate ID." } + }, + "required": ["candidate_id"] + }, + "endpointMapping": { "method": "GET", "path": "/candidates/{candidate_id}" } + }, + { + "name": "workable_create_candidate", + "description": "Create a candidate on a job (sourced / referral). Required: candidate.firstname, candidate.lastname, candidate.email.", + "parameters": { + "type": "object", + "properties": { + "shortcode": { "type": "string", "description": "Job shortcode." }, + "sourced": { "type": "boolean", "description": "If true, marks as sourced; otherwise applied." }, + "candidate": { "type": "object", "description": "Candidate object {firstname, lastname, email, phone, address, headline, summary, resume_url, ...}." } + }, + "required": ["shortcode", "candidate"] + }, + "endpointMapping": { + "method": "POST", + "path": "/jobs/{shortcode}/candidates", + "bodyMapping": { "sourced": "$sourced", "candidate": "$candidate" } + } + }, + { + "name": "workable_move_candidate_stage", + "description": "Move a candidate to a different pipeline stage.", + "parameters": { + "type": "object", + "properties": { + "candidate_id": { "type": "string", "description": "Candidate ID." }, + "target_stage": { "type": "string", "description": "Target stage slug." }, + "member_id": { "type": "string", "description": "Member performing the move." } + }, + "required": ["candidate_id", "target_stage"] + }, + "endpointMapping": { + "method": "POST", + "path": "/candidates/{candidate_id}/move", + "bodyMapping": { "target_stage": "$target_stage", "member_id": "$member_id" } + } + }, + { + "name": "workable_disqualify_candidate", + "description": "Disqualify (reject) a candidate with a reason.", + "parameters": { + "type": "object", + "properties": { + "candidate_id": { "type": "string", "description": "Candidate ID." }, + "disqualification_reason_id": { "type": "string", "description": "Reason ID configured in Workable." }, + "member_id": { "type": "string", "description": "Member performing the disqualification." } + }, + "required": ["candidate_id"] + }, + "endpointMapping": { + "method": "POST", + "path": "/candidates/{candidate_id}/disqualify", + "bodyMapping": { + "disqualification_reason_id": "$disqualification_reason_id", + "member_id": "$member_id" + } + } + }, + { + "name": "workable_list_stages", + "description": "List pipeline stages defined in the account.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/stages" } + } + ] +} diff --git a/packages/backend/src/adapters/intl/workable.live.spec.ts b/packages/backend/src/adapters/intl/workable.live.spec.ts new file mode 100644 index 0000000..b59feef --- /dev/null +++ b/packages/backend/src/adapters/intl/workable.live.spec.ts @@ -0,0 +1,7 @@ +import * as adapter from './workable.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string } }; +describe('workable adapter — static spec conformance', () => { + it('per-tenant baseUrl with subdomain placeholder', () => + expect(a.connector.baseUrl).toBe('https://{{WORKABLE_SUBDOMAIN}}.workable.com/spi/v3')); + it('Bearer auth', () => expect(a.connector.authType).toBe('BEARER_TOKEN')); +}); diff --git a/packages/backend/src/adapters/intl/wrike.json b/packages/backend/src/adapters/intl/wrike.json new file mode 100644 index 0000000..3499de2 --- /dev/null +++ b/packages/backend/src/adapters/intl/wrike.json @@ -0,0 +1,219 @@ +{ + "slug": "wrike", + "name": "Wrike", + "description": "Manage Wrike (work management: tasks, folders, projects, comments, timelogs, attachments) from any AI agent. 10 tools, Bearer token.", + "instructions": "This connector wraps the Wrike API v4 (per-host).\n\n**Setup**:\n1. Log into your Wrike account → top-right profile → **Apps & Integrations → API → Create new application**.\n2. Generate a permanent access token. Note your host — most accounts are `www.wrike.com`, but EU accounts use `app-eu.wrike.com`. Change baseUrl accordingly.\n3. Set `WRIKE_ACCESS_TOKEN`.\n\n**Authentication**: `Authorization: Bearer ${WRIKE_ACCESS_TOKEN}`.\n\n**Entity model**: `Account → Folder/Project → Task → Subtask`. A folder can contain folders + tasks. Projects are folders with `project: true` metadata.\n\n**ID format**: Wrike IDs are short opaque strings (`IEAAGYTSI4ABCDEF`). Always discover via list endpoints; don't construct.\n\n**Pagination**: cursor-based via `nextPageToken`. Pass back as `nextPageToken` query param.\n\n**Rate limits**: 100 req / 60s per token. 429 with `X-Rate-Limit-Remaining`.\n\n**Out of scope here**: blueprints, custom workflows CRUD, dashboard widgets, approvals, the Wrike Integrate (Workato wrap) extras.", + "region": "intl", + "category": "project-management", + "icon": "wrike", + "docsUrl": "https://developers.wrike.com/", + "requiredEnvVars": ["WRIKE_ACCESS_TOKEN"], + "connector": { + "name": "Wrike API v4", + "type": "REST", + "baseUrl": "https://www.wrike.com/api/v4", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{WRIKE_ACCESS_TOKEN}}" + } + }, + "tools": [ + { + "name": "wrike_get_account", + "description": "Return account info.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/account" } + }, + { + "name": "wrike_list_folders", + "description": "List all top-level folders + projects in the account.", + "parameters": { + "type": "object", + "properties": { + "permalink": { "type": "string", "description": "Direct lookup by full Wrike URL." }, + "descendants": { "type": "boolean", "description": "If true include descendants tree." }, + "metadata": { "type": "string", "description": "JSON-encoded metadata filter." }, + "fields": { "type": "string", "description": "Optional fields: ['parentIds','childIds','customFields','project']." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/folders", + "queryParams": { + "permalink": "$permalink", + "descendants": "$descendants", + "metadata": "$metadata", + "fields": "$fields" + } + } + }, + { + "name": "wrike_get_folder", + "description": "Get a folder/project by ID with details.", + "parameters": { + "type": "object", + "properties": { + "folder_id": { "type": "string", "description": "Folder ID." } + }, + "required": ["folder_id"] + }, + "endpointMapping": { "method": "GET", "path": "/folders/{folder_id}" } + }, + { + "name": "wrike_create_folder", + "description": "Create a folder or project inside a parent folder.", + "parameters": { + "type": "object", + "properties": { + "parent_folder_id": { "type": "string", "description": "Parent folder ID." }, + "title": { "type": "string", "description": "Folder/project title." }, + "description": { "type": "string", "description": "Description." }, + "shareds": { "type": "array", "description": "Array of user IDs to share with." }, + "project": { "type": "object", "description": "If present, makes it a project: {ownerIds, status, startDate, endDate}." } + }, + "required": ["parent_folder_id", "title"] + }, + "endpointMapping": { + "method": "POST", + "path": "/folders/{parent_folder_id}/folders", + "bodyMapping": { + "title": "$title", + "description": "$description", + "shareds": "$shareds", + "project": "$project" + } + } + }, + { + "name": "wrike_list_tasks", + "description": "List tasks. Filter by folder, status, assignee, completed/scheduled date range.", + "parameters": { + "type": "object", + "properties": { + "folder_id": { "type": "string", "description": "Filter to folder's tasks." }, + "status": { "type": "string", "description": "Active, Completed, Deferred, Cancelled." }, + "responsibles": { "type": "string", "description": "JSON array of user IDs." }, + "scheduledDate": { "type": "string", "description": "JSON {start, end} window." }, + "fields": { "type": "string", "description": "Optional fields list." }, + "nextPageToken": { "type": "string", "description": "Pagination cursor." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/tasks", + "queryParams": { + "folderId": "$folder_id", + "status": "$status", + "responsibles": "$responsibles", + "scheduledDate": "$scheduledDate", + "fields": "$fields", + "nextPageToken": "$nextPageToken" + } + } + }, + { + "name": "wrike_get_task", + "description": "Get one task with full custom fields, dates, parents, shareds.", + "parameters": { + "type": "object", + "properties": { + "task_id": { "type": "string", "description": "Task ID." } + }, + "required": ["task_id"] + }, + "endpointMapping": { "method": "GET", "path": "/tasks/{task_id}" } + }, + { + "name": "wrike_create_task", + "description": "Create a task in a folder.", + "parameters": { + "type": "object", + "properties": { + "folder_id": { "type": "string", "description": "Parent folder ID." }, + "title": { "type": "string", "description": "Task title." }, + "description": { "type": "string", "description": "Description (HTML allowed)." }, + "responsibles": { "type": "array", "description": "Array of user IDs to assign." }, + "status": { "type": "string", "description": "Active, Completed, Deferred, Cancelled." }, + "importance": { "type": "string", "description": "Low, Normal, High." }, + "dates": { "type": "object", "description": "{type:'Backlog'|'Milestone'|'Planned', start:'YYYY-MM-DD', due:'YYYY-MM-DD', duration: }." } + }, + "required": ["folder_id", "title"] + }, + "endpointMapping": { + "method": "POST", + "path": "/folders/{folder_id}/tasks", + "bodyMapping": { + "title": "$title", + "description": "$description", + "responsibles": "$responsibles", + "status": "$status", + "importance": "$importance", + "dates": "$dates" + } + } + }, + { + "name": "wrike_update_task", + "description": "Update task fields.", + "parameters": { + "type": "object", + "properties": { + "task_id": { "type": "string", "description": "Task ID." }, + "title": { "type": "string", "description": "New title." }, + "description": { "type": "string", "description": "New description." }, + "status": { "type": "string", "description": "New status." }, + "addResponsibles": { "type": "array", "description": "User IDs to add." }, + "removeResponsibles": { "type": "array", "description": "User IDs to remove." }, + "dates": { "type": "object", "description": "Dates structure." } + }, + "required": ["task_id"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/tasks/{task_id}", + "bodyMapping": { + "title": "$title", + "description": "$description", + "status": "$status", + "addResponsibles": "$addResponsibles", + "removeResponsibles": "$removeResponsibles", + "dates": "$dates" + } + } + }, + { + "name": "wrike_add_comment", + "description": "Add a comment to a task.", + "parameters": { + "type": "object", + "properties": { + "task_id": { "type": "string", "description": "Task ID." }, + "text": { "type": "string", "description": "Comment body (Markdown supported)." }, + "plain_text": { "type": "boolean", "description": "If true, render as plain text." } + }, + "required": ["task_id", "text"] + }, + "endpointMapping": { + "method": "POST", + "path": "/tasks/{task_id}/comments", + "bodyMapping": { "text": "$text", "plainText": "$plain_text" } + } + }, + { + "name": "wrike_list_contacts", + "description": "List users (Wrike calls them 'contacts').", + "parameters": { + "type": "object", + "properties": { + "me": { "type": "boolean", "description": "If true, only the authenticated user." }, + "metadata": { "type": "string", "description": "JSON-encoded metadata filter." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "/contacts", + "queryParams": { "me": "$me", "metadata": "$metadata" } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/wrike.live.spec.ts b/packages/backend/src/adapters/intl/wrike.live.spec.ts new file mode 100644 index 0000000..55038cb --- /dev/null +++ b/packages/backend/src/adapters/intl/wrike.live.spec.ts @@ -0,0 +1,7 @@ +import * as adapter from './wrike.json'; +const a = adapter as unknown as { connector: { baseUrl: string; authType: string } }; +describe('wrike adapter — static spec conformance', () => { + it('www.wrike.com/api/v4 base URL (US default)', () => + expect(a.connector.baseUrl).toBe('https://www.wrike.com/api/v4')); + it('Bearer auth', () => expect(a.connector.authType).toBe('BEARER_TOKEN')); +}); diff --git a/packages/backend/src/adapters/intl/zoho-crm.json b/packages/backend/src/adapters/intl/zoho-crm.json new file mode 100644 index 0000000..52a8004 --- /dev/null +++ b/packages/backend/src/adapters/intl/zoho-crm.json @@ -0,0 +1,228 @@ +{ + "slug": "zoho-crm", + "name": "Zoho CRM", + "description": "Manage Zoho CRM (leads, contacts, accounts, deals, tasks, notes, custom modules) from any AI agent. 10 tools, OAuth refresh-token auth.", + "instructions": "This connector wraps the Zoho CRM API v8 (www.zohoapis.com/crm/v8).\n\n**Setup — OAuth client + refresh token**:\n1. Go to https://api-console.zoho.com → **Add Client → Self Client**. Generate a 'grant token' with scope `ZohoCRM.modules.ALL,ZohoCRM.settings.READ,ZohoCRM.users.READ` and copy it.\n2. Exchange it for a refresh token via:\n `curl -X POST 'https://accounts.zoho.com/oauth/v2/token?grant_type=authorization_code&client_id=CLIENT_ID&client_secret=CLIENT_SECRET&code=GRANT_TOKEN'`\n3. Save the `refresh_token` from the response. Set `ZOHO_CLIENT_ID`, `ZOHO_CLIENT_SECRET`, `ZOHO_REFRESH_TOKEN`.\n4. **Data centre matters**: Zoho hosts data in regional clusters: `.com` (US, default), `.eu` (EU), `.in` (India), `.com.au` (AU), `.jp` (Japan). If your org is on a non-US DC change baseUrl + token URL accordingly — wrong DC returns `INVALID_TOKEN`.\n\n**Authentication**: the engine handles the OAuth2 refresh flow — every call exchanges the refresh token for a short-lived (1h) access token and sends it as `Authorization: Zoho-oauthtoken ACCESS_TOKEN`. Zoho uses `Zoho-oauthtoken` instead of `Bearer` — non-standard but documented.\n\n**Module API**: every record type (Leads/Contacts/Accounts/Deals/Tasks/...) lives at `/crm/v8/{module}`. Custom modules use their API name (`Cf__Custom_Module1`). List of module API names is exposed via the modules metadata endpoint.\n\n**Bulk writes**: insert/update endpoints expect `{data: [{...}, {...}], trigger: ['approval','workflow','blueprint']}` shape — wrap a single record in an array.\n\n**Pagination**: `page` + `per_page` (max 200). Response has `info: {more_records: bool, page: N, per_page: N}`.\n\n**Rate limits**: 50 req/min per OAuth client + daily credits (5k on free, 25k on Standard, 250k on Enterprise). 429 → exponential backoff.\n\n**Out of scope here**: bulk import (CSV upload + polling), territory rules CRUD, workflow execution, Sandbox switching.", + "region": "intl", + "category": "crm", + "icon": "zoho-crm", + "docsUrl": "https://www.zoho.com/crm/developer/docs/api/v8/", + "requiredEnvVars": ["ZOHO_CLIENT_ID", "ZOHO_CLIENT_SECRET", "ZOHO_REFRESH_TOKEN"], + "connector": { + "name": "Zoho CRM API v8", + "type": "REST", + "baseUrl": "https://www.zohoapis.com/crm/v8", + "authType": "OAUTH2", + "authConfig": { + "clientId": "{{ZOHO_CLIENT_ID}}", + "clientSecret": "{{ZOHO_CLIENT_SECRET}}", + "refreshToken": "{{ZOHO_REFRESH_TOKEN}}", + "tokenUrl": "https://accounts.zoho.com/oauth/v2/token", + "tokenPrefix": "Zoho-oauthtoken" + } + }, + "tools": [ + { + "name": "zoho_crm_list_records", + "description": "List records of any module (Leads, Contacts, Accounts, Deals, Tasks, custom). Pass module API name. Use `fields` to project specific fields and limit payload size.", + "parameters": { + "type": "object", + "properties": { + "module": { "type": "string", "description": "Module API name: Leads, Contacts, Accounts, Deals, Tasks, Calls, Notes, ..." }, + "fields": { "type": "string", "description": "Comma-separated field API names to return (highly recommended for performance)." }, + "page": { "type": "integer", "description": "1-based page index." }, + "per_page": { "type": "integer", "description": "Max 200, default 200." }, + "sort_by": { "type": "string", "description": "Field API name to sort by." }, + "sort_order": { "type": "string", "description": "'asc' or 'desc'." } + }, + "required": ["module"] + }, + "endpointMapping": { + "method": "GET", + "path": "/{module}", + "queryParams": { + "fields": "$fields", + "page": "$page", + "per_page": "$per_page", + "sort_by": "$sort_by", + "sort_order": "$sort_order" + } + } + }, + { + "name": "zoho_crm_get_record", + "description": "Get a single record by ID from any module.", + "parameters": { + "type": "object", + "properties": { + "module": { "type": "string", "description": "Module API name." }, + "record_id": { "type": "string", "description": "Record ID (Zoho's internal numeric/string ID)." }, + "fields": { "type": "string", "description": "Comma-separated field API names." } + }, + "required": ["module", "record_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/{module}/{record_id}", + "queryParams": { "fields": "$fields" } + } + }, + { + "name": "zoho_crm_search_records", + "description": "Search records using one of three modes: `criteria` (Zoho's COQL-like criteria syntax), `email`, `phone`, or `word` (text search). Returns matching records with all fields.", + "parameters": { + "type": "object", + "properties": { + "module": { "type": "string", "description": "Module API name." }, + "criteria": { "type": "string", "description": "Criteria expression, e.g. '(Last_Name:equals:Doe)'." }, + "email": { "type": "string", "description": "Exact-email match." }, + "phone": { "type": "string", "description": "Exact-phone match." }, + "word": { "type": "string", "description": "Substring search across primary fields." }, + "page": { "type": "integer", "description": "1-based page." }, + "per_page": { "type": "integer", "description": "Max 200." } + }, + "required": ["module"] + }, + "endpointMapping": { + "method": "GET", + "path": "/{module}/search", + "queryParams": { + "criteria": "$criteria", + "email": "$email", + "phone": "$phone", + "word": "$word", + "page": "$page", + "per_page": "$per_page" + } + } + }, + { + "name": "zoho_crm_create_records", + "description": "Create one or more records in a module. Pass `data` as an array even for one record. Returns per-record success/failure with the new record IDs.", + "parameters": { + "type": "object", + "properties": { + "module": { "type": "string", "description": "Module API name." }, + "data": { "type": "array", "description": "Array of record objects with field-API-name → value pairs." }, + "trigger": { "type": "array", "description": "Array of triggers to fire: 'approval', 'workflow', 'blueprint'. Empty to skip all." } + }, + "required": ["module", "data"] + }, + "endpointMapping": { + "method": "POST", + "path": "/{module}", + "bodyMapping": { "data": "$data", "trigger": "$trigger" } + } + }, + { + "name": "zoho_crm_update_records", + "description": "Update one or more records. Each item in `data` must include `id` of the record to update.", + "parameters": { + "type": "object", + "properties": { + "module": { "type": "string", "description": "Module API name." }, + "data": { "type": "array", "description": "Array of record objects, each must include `id` + the fields to change." }, + "trigger": { "type": "array", "description": "Triggers to fire (see create)." } + }, + "required": ["module", "data"] + }, + "endpointMapping": { + "method": "PUT", + "path": "/{module}", + "bodyMapping": { "data": "$data", "trigger": "$trigger" } + } + }, + { + "name": "zoho_crm_upsert_records", + "description": "Insert OR update by duplicate-check fields. Useful when sync'ing from external systems. Pass `duplicate_check_fields` (comma-separated field API names) to define how matches are detected.", + "parameters": { + "type": "object", + "properties": { + "module": { "type": "string", "description": "Module API name." }, + "data": { "type": "array", "description": "Array of record objects." }, + "duplicate_check_fields": { "type": "array", "description": "Field API names used to detect duplicates." }, + "trigger": { "type": "array", "description": "Triggers." } + }, + "required": ["module", "data"] + }, + "endpointMapping": { + "method": "POST", + "path": "/{module}/upsert", + "bodyMapping": { + "data": "$data", + "duplicate_check_fields": "$duplicate_check_fields", + "trigger": "$trigger" + } + } + }, + { + "name": "zoho_crm_delete_record", + "description": "Delete a single record by ID. Optionally trigger workflows.", + "parameters": { + "type": "object", + "properties": { + "module": { "type": "string", "description": "Module API name." }, + "record_id": { "type": "string", "description": "Record ID." }, + "wf_trigger": { "type": "boolean", "description": "Fire workflows on delete." } + }, + "required": ["module", "record_id"] + }, + "endpointMapping": { + "method": "DELETE", + "path": "/{module}/{record_id}", + "queryParams": { "wf_trigger": "$wf_trigger" } + } + }, + { + "name": "zoho_crm_list_notes", + "description": "List notes attached to a record (works on any module).", + "parameters": { + "type": "object", + "properties": { + "module": { "type": "string", "description": "Parent module API name." }, + "record_id": { "type": "string", "description": "Parent record ID." }, + "page": { "type": "integer", "description": "1-based." }, + "per_page": { "type": "integer", "description": "Max 200." } + }, + "required": ["module", "record_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "/{module}/{record_id}/Notes", + "queryParams": { "page": "$page", "per_page": "$per_page" } + } + }, + { + "name": "zoho_crm_create_note", + "description": "Add a note to a record.", + "parameters": { + "type": "object", + "properties": { + "module": { "type": "string", "description": "Parent module API name." }, + "record_id": { "type": "string", "description": "Parent record ID." }, + "note_title": { "type": "string", "description": "Note title." }, + "note_content": { "type": "string", "description": "Note body." } + }, + "required": ["module", "record_id", "note_content"] + }, + "endpointMapping": { + "method": "POST", + "path": "/{module}/{record_id}/Notes", + "bodyMapping": { + "data": [ + { + "Note_Title": "$note_title", + "Note_Content": "$note_content" + } + ] + } + } + }, + { + "name": "zoho_crm_list_modules", + "description": "List all modules in the org (standard + custom) with their API names, display names, and whether they're CRM-modifiable. Use this to discover which `module` strings you can pass to the other tools.", + "parameters": { "type": "object", "properties": {} }, + "endpointMapping": { "method": "GET", "path": "/settings/modules" } + } + ] +} diff --git a/packages/backend/src/adapters/intl/zoho-crm.live.spec.ts b/packages/backend/src/adapters/intl/zoho-crm.live.spec.ts new file mode 100644 index 0000000..dc7b1bb --- /dev/null +++ b/packages/backend/src/adapters/intl/zoho-crm.live.spec.ts @@ -0,0 +1,13 @@ +import * as adapter from './zoho-crm.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: { tokenUrl: string; tokenPrefix: string } }; +}; +describe('zoho-crm adapter — static spec conformance', () => { + it('zohoapis.com/crm/v8 base URL (US DC default)', () => + expect(a.connector.baseUrl).toBe('https://www.zohoapis.com/crm/v8')); + it('OAuth2 with Zoho-oauthtoken header prefix', () => { + expect(a.connector.authType).toBe('OAUTH2'); + expect(a.connector.authConfig.tokenUrl).toBe('https://accounts.zoho.com/oauth/v2/token'); + expect(a.connector.authConfig.tokenPrefix).toBe('Zoho-oauthtoken'); + }); +}); diff --git a/packages/backend/src/connectors/engines/rest.engine.ts b/packages/backend/src/connectors/engines/rest.engine.ts index 46775f4..8f3928a 100644 --- a/packages/backend/src/connectors/engines/rest.engine.ts +++ b/packages/backend/src/connectors/engines/rest.engine.ts @@ -239,9 +239,11 @@ export class RestEngine { config.authConfig, config.connectorId, ); + // Some vendors use a non-standard prefix (e.g. Zoho: "Zoho-oauthtoken"). + const prefix = String(config.authConfig?.tokenPrefix ?? 'Bearer'); axiosConfig.headers = { ...axiosConfig.headers, - Authorization: `Bearer ${accessToken}`, + Authorization: `${prefix} ${accessToken}`, }; break; }