Docs

Connections

Connector CRUD over the API and MCP - browse the catalog, connect a source, watch its syncs, and fix it when it breaks, with display metadata on every read

Connections are how a workspace hooks up its first-party data — CRM, call recordings, comms, docs, support tickets, and tracked social handles. The whole lifecycle is addressable here: browse what can be connected, connect it, watch it sync, and fix it when it breaks. Every read is self-describing — the catalog tells you each connector's display metadata (name, logo, category) and exactly which fields its connect flow needs, so a client renders the whole surface from the API with no hardcoded connector list.

OperationRESTMCPScope
The connector catalogconnections.list_catalogGET /connections/cataloglist_connectorsconnections:read
Connected instancesconnections.listGET /connectionslist_connectionsconnections:read
One connectionconnections.getGET /connections/:idget_connectionconnections:read
Status (for polling)connections.get_statusGET /connections/:id/statusget_connection_statusconnections:read
Sync-run historyconnections.list_runsGET /connections/:id/runslist_connection_syncsconnections:read
Data summaryconnections.get_summaryGET /connections/:id/summaryget_connection_summaryconnections:read
Setup checksetup.statusGET /setup/statusget_setup_statusdata:read
Connect a sourceconnections.connectPOST /connectionsconnect_sourceconnections:write
Disconnectconnections.disconnectDELETE /connections/:iddisconnect_connectionconnections:delete
Set the ownerconnections.updatePATCH /connections/:idupdate_connectionconnections:write
Repair in placeconnections.reconnectPOST /connections/:id/reconnectreconnect_connectionconnections:write
Call filtersconnections.set_call_filtersPATCH /connections/:id/call-filters—connections:write
CRM recency windowconnections.set_crm_recencyPATCH /connections/:id/crm-recency—connections:write
Comms filterconnections.set_comms_filterPATCH /connections/:id/comms-filter—connections:write
GET/connections/catalogauth required

Everything that CAN be connected - one entry per connector, carrying display metadata, the connect-flow spec, and whether it is connectable right now.

GET/connectionsauth required

Everything that IS connected - newest first, each row carrying live health and its connector's display block.

POST/connectionsauth required

Connect a source. One endpoint covers every flow: an API key connects in one call, a handle resolves and connects, OAuth hands back an authorize URL.

GET/setup/statusauth required

Is this credential set up? The workspace and caller it resolves to, whether it can optimize (and if not, why), and which connections need attention.

Which keys can call what

The six reads authorize for every key tier, including read-only MCP keys — browsing the catalog and checking a connection's health is safe everywhere. The four lifecycle writes (connect / disconnect / update / reconnect) and the three config writes need connections:write (or connections:delete for disconnect), which is an editor-level grant:

  • OAuth tokens (a human member's delegated session — Claude Desktop and other MCP clients connected via OAuth) carry the write scopes when the user approved them. A client that sends no scope gets them by default; one that asked for less must send the user through authorization again. The token re-checks the member's workspace membership on every request, so revoking the member revokes the capability.
  • API key bundles do not carry the write scopes. A headless agent key can read everything here but cannot connect or disconnect a source; connector lifecycle stays with a person (or a person's delegated session).

The three set_* config writes are REST-only: no MCP tool runs them. The connect_source tool accepts an inline call_filters object instead, so an MCP connect of a call recorder never lands in the paused-until-configured state.

Check your setup

GET /setup/status answers "is this set up, and if not, what do I fix?" in one read. Over MCP it is the get_setup_status tool, which takes no parameters. Call it first when a call is refused for scope, role or quota. It changes nothing, and it needs only data:read, which every key and connector carries.

bash
curl -s "$AMDAHL_BASE/setup/status" -H "X-API-Key: $AMDAHL_KEY"
json
{
  "data": {
    "workspace": { "id": "0b6f…", "name": "Acme", "slug": "acme" },
    "caller": {
      "user_id": "5d2e…",
      "email": "[email protected]",
      "role": "editor",
      "auth_method": "api_key",
      "client_id": null,
      "scopes": ["data:read", "messages:execute", "…"]
    },
    "optimize": {
      "allowed": true,
      "blocker": null,
      "quota": { "limit": 1000, "used": 12, "remaining": 988, "resets_at": "2026-11-01T00:00:00Z" }
    },
    "connections": {
      "total": 3,
      "healthy": 2,
      "needs_attention": [
        { "id": "9a41…", "connector_type": "hubspot", "name": "HubSpot", "status": "needs_reauth" }
      ]
    }
  }
}
FieldNotes
workspaceThe workspace this credential acts in: id, name, slug. null only when the workspace record cannot be read.
callerWho is calling and how. role is owner, admin, editor or viewer. auth_method is oauth, api_key or session. client_id names the OAuth client for an OAuth token, and is null otherwise. scopes are the effective scopes after the role clamp.
optimize.allowedWhether messages.optimize would be accepted right now.
optimize.blockernull when allowed. Otherwise the one thing to fix: missing_scope (the credential lacks messages:execute: create a Customer agent key, or reconnect the connector), role_too_low (a Viewer: ask a workspace admin for Editor or above) or quota_exhausted (wait until quota.resets_at). When more than one applies, it names the first in that order: role, then scope, then quota. It is computed from the same role and scope check the optimize call runs, so the two cannot disagree.
optimize.quotaThis month's optimizations for API and connector calls: limit, used, remaining, resets_at. null for a caller that is not metered, such as a console session, and when the workspace has no monthly cap.
connectionstotal connected sources, how many are healthy, and the ones that needs_attention, each with id, connector_type, name and status. status is the connection's health: anything other than healthy or syncing, such as paused, needs_reauth, error, rate_limited, degraded or stale. Without connections:read this section is { "omitted": "missing_scope" }; if the connections cannot be read for another reason it is { "omitted": "unavailable" }. The rest of the response is unchanged either way.

The response is data only. Turn a blocker into words for your user; the Optimizer skill reference has the wording.

A workspace with nothing connected

Search, research, evals and an evidence-graded optimize all read the workspace's own conversations. A workspace with no connected source has none, so each of those answers adds a setup_needed field that says so:

OperationWhen it carries setup_needed
search.queryThe result is empty and the workspace has no connected source. Empty means no rows, or the one row of zeros a count returns over nothing ([{ "count": 0 }]).
research.startThe workspace has no connected source. If the workspace also holds no conversation themes, the start is refused (see below). Otherwise the job still starts.
evals.runThe workspace has no connected source. If the workspace also holds no conversation themes and the run could only end not_applicable (empty_corpus), it is refused (see below). Otherwise the run is still queued.
messages.optimizeYou sent evidence: "workspace", no conversations were read, and the workspace has no connected source. The rewrite is still returned.
messages.optimize_batchAn item has evidence: "workspace" and the workspace has no connected source. The batch is still queued, and those items are rewritten without customer evidence.
json
"setup_needed": {
  "reason": "no_sources_connected",
  "message": "This workspace has no data connected yet, so there is nothing to search. Connect your CRM or call recorder at https://console.amdahl.ai/acme/settings/connections, then ask again.",
  "connect_url": "https://console.amdahl.ai/acme/settings/connections"
}

Give the user message and connect_url. The status code, success and every other field stay as they were, so a client that does not know the field is not broken by it.

"Connected source" means what the rest of this page means: a connection that GET /connections lists by default, which is the total that GET /setup/status (the MCP get_setup_status tool) reports in its connections section. A source that was disconnected after it synced also counts, because its conversations are still searchable. A connect that was started and never finished does not count. Once a source is connected the field goes away on the next call. "Connected" is cached for 60 seconds per workspace, so the field can take up to a minute to come back after the last source is removed. The field is left out when the server cannot check.

Refused before the quota. A research job, or an eval run whose every case grades against customer conversations, can only come back empty on a workspace with nothing to read, yet it still uses a monthly quota call. So when the workspace has no connected source AND holds no conversation themes, research.start and such an evals.run are refused with 409 before anything is counted:

json
{
  "success": false,
  "error": {
    "code": "setup_needed",
    "message": "This workspace has no data connected yet, so there is nothing to search. Connect your CRM or call recorder at https://console.amdahl.ai/acme/settings/connections, then ask again.",
    "details": { "reason": "no_sources_connected", "connect_url": "https://console.amdahl.ai/acme/settings/connections" }
  },
  "setup_needed": { "reason": "no_sources_connected", "message": "…", "connect_url": "…" }
}

An eval run is not refused when it can grade without the workspace's conversations: with include_external: true, with evidence_from_run or candidate_from_run, or when the eval has a case of only rule or structure graders. Nothing is refused when the check cannot be made. Retrying the same call fails the same way until a source is connected. How a client should handle both forms is in Errors; common questions are in the FAQ.

The catalog is self-describing

Each GET /connections/catalog entry carries everything a client needs to render and drive the connector with no hardcoded knowledge:

FieldNotes
typeThe connector id (hubspot, gong, attio, …). What you pass to connect.
nameDisplay name ("HubSpot").
kindfirst_party.
categorycrm | calls | comms | docs | support | social — the grouping a picker renders.
authMethodoauth | api_key | handle | webhook — which connect flow applies.
logo{ key, url, monogram }. url is an absolute asset URL, or null when no asset ships — then render the two-letter monogram. Never a fabricated path.
connect{ method, fields, keyUrl } — the connect-flow spec. See below.
connectableWhether connect will accept this type right now; unavailableReason says why not (e.g. a coming-soon connector).
single_instanceThe effective connect rule. Read this — never the raw instancing capability — when deciding whether to offer a second connect.
ownershipworkspace (one shared connection) | personal (per-member, e.g. Gmail / Outlook — update can set its owner).
description, dataStreamsWhat the connector is and what it pulls.

The connect spec

connect.fields declares exactly what input the connect flow needs, so a form (or an agent) is generated from the catalog:

  • api_key connectors declare one field: { key: 'api_key', label, secret: true, placeholder, help }. placeholder shows the key's shape when the vendor pins one (sk-apik_... for AskElephant) and is null otherwise — a made-up key shape is worse than no hint. help carries the vendor-specific scope guidance, which is worth surfacing: most connector failures are not a wrong key but a key that cannot see enough, and that presents as an empty workspace rather than as an error.
  • handle connectors (X / LinkedIn) declare { key: 'account_handle', secret: false } — the public handle to track.
  • oauth connectors declare fields: [] — the flow is a redirect, not a form. See the OAuth handoff below.

connect.keyUrl is the vendor page where the customer creates the credential, or null when there is nowhere to send them (an OAuth redirect, a social handle, or a deployment whose token you issue yourself). Render it as a link next to the key field.

A few connectors ask for more than one field, and the spec is how you know. Never assume api_key means exactly one input — read connect.fields and render what it declares:

  • Aircall declares a non-secret api_id ahead of the key, because its API is HTTP Basic and needs both halves of the pair. Sending only api_key is refused with invalid_argument.
  • Computer Agent declares a non-secret base_url ahead of the key — the deployment to drive.

Each field also carries resupplyOnReconnect, which is what a reconnect form should filter on. A credential is replaced wholesale on re-key, so every half of it must be collected again (api_key, and Aircall's api_id), while a field stored on the connection row is not (base_url — a re-key keeps the deployment it was pointed at). Guessing is silent both ways: hide a credential half and the reconnect is refused with no field to fix it; show a stored-config field and you collect a value reconnect drops.

Connecting

POST /connections takes { connector_type, ... } and routes on the connector's auth_method. The response's mode discriminator tells you what happened:

modeWhenWhat you got
connectedapi_key or handle connectA live connection — the credential was stored (or the handle resolved) and the first sync was triggered.
oauth_redirectoauth connectAn authorize_url. Send the human there; the provider callback completes the connection server-side. Poll GET /connections/:id/status until health leaves syncing.
already_connectedA duplicateThe existing connection, unchanged. Workspace-owned data sources are single-instance; a second connect of the same handle dedupes.

Per-flow request fields: api_key connectors take api_key (plus api_id where the catalog declares that field, and optionally call_filters for call recorders — atomic with the connect, so the source is never created paused-but-unconfigured); handle connectors take account_handle; all flows accept an optional display name. A connector whose catalog entry says connectable: false is refused up front with connector_not_connectable.

Attio call recordings

An Attio connection syncs call recordings and transcripts alongside the CRM objects — an Attio-only workspace gets its call transcripts through the one connection, with no separate call recorder. The workspace API key needs three extra scopes for that: meeting:read, record_permission:read, and call_recording:read. A key without them still connects and keeps the CRM objects syncing; the two recording streams fail on their own while everything else flows. Tasks and workspace members likewise need user_management:read (task:read alone is not enough). To turn a stream on later, a workspace admin adds the missing scope to the existing key in Attio (Workspace settings → Developers → the integration → Scopes), and the next sync picks it up; a re-issued key goes through POST /connections/:id/reconnect instead. When a check finds a missing scope, GET /connections/:id names it in provider_check.warnings (see Watching).

Attio emails

An Attio connection can also sync email activity from the mailboxes synced to Attio. This stream is optional. It is on only when the workspace API key has the Emails scope (email:read), and a key without that scope syncs everything else as before. Attio's emails API is in alpha, so Attio must enable it for your workspace before Attio offers the scope. Ask Attio support to enable it. Then a workspace admin adds the scope to the existing key (Workspace settings → Developers → the integration → Scopes), and the next sync picks it up. Amdahl receives email metadata only: when the email was sent, its direction, the subject line, the participants, and the companies and people it is linked to in Attio. Attio never returns the email body. A key without email:read does not cause a provider_check warning, because the stream is off by choice, not by error.

Zero API keys

Zero's API is in beta, so the Zero team must turn on API access for the workspace before a key works. A Zero key is a personal API key, and it stops working in three cases: it expires, its owner leaves the workspace, or its owner signs out of all sessions. Create the key as a workspace admin who will stay, and choose the longest expiry Zero offers. A dead key fails every sync with an auth error. To fix it, create a new key and send it through POST /connections/:id/reconnect. The key reads every CRM record, but only the meetings and transcripts that its owner can see.

A key that reaches more than one Zero workspace cannot be connected yet. Amdahl syncs one workspace per connection and does not guess which one you mean, so the connect-time check fails and every sync fails with a config error.

Watching

  • GET /connections rows each carry a connector block — { type, name, category, logo } — so a list renders names and logos straight off the read, plus derived health (healthy | syncing | stale | degraded | needs_reauth | rate_limited | error | paused | disconnected) and last_run. paused means Amdahl has switched off syncing for the whole workspace: no source syncs until it is switched back on, and connections.reconnect does not change that. rate_limited, degraded, and an error from a failed run describe the last run, so they hold only while that run is recent: once it finished more than 48 hours ago, the connection reads stale. An error status on the connection itself, and needs_reauth, do not expire.
  • GET /connections/:id returns one connection with the fields of a list row, plus provider_check: the newest successful check that Amdahl ran against the provider for this connection, as { checked_at, warnings }. checked_at is when the check asked the provider. warnings lists the problems the check found, as plain sentences you can show to a person, for example an Attio key that lacks a scope that tasks need. An empty list means the check found no problem. Amdahl re-checks most connectors about once a day, but not all of them, so read checked_at for the age of the result. A newer check that failed or is still running does not replace the last successful one. provider_check is null when the connector does not support checks, when no check has succeeded yet, or when the check could not be read. Only this read carries provider_check: rows from GET /connections and the /status read do not have the field.
  • GET /connections/:id/status is the lean polling read: status, health, is_syncing, and nothing heavy. Use it for a connecting / syncing badge.
  • GET /connections/:id/runs is the sync-run history, newest first — per run: status, started_at / finished_at, streams_total / streams_failed, records_written, and a bucketed error_reason (auth | rate_limit | transient | config | unknown). Raw provider error text is never surfaced.
  • GET /connections/:id/summary is a glanceable snapshot of the connection's own synced data — per-category stats (deals and contacts for a CRM, calls for a recorder), computed live. A connector that has not synced yet degrades to a partial summary with null stats, never an error; null never renders as 0.

Fixing

POST /connections/:id/reconnect repairs a broken connection in place — it never creates a second row, so the connection's history and its warehouse data stay attached. Per flow: oauth re-mints the authorize URL, api_key takes a fresh api_key (and api_id where the catalog declares it — re-keying stores the whole pair), handle clears the error and re-triggers the sync. A connection that is already healthy is refused with invalid_argument — there is nothing to restore; disconnect first if you mean to replace it.

DELETE /connections/:id disconnects — the row is retained (status disconnected) so a later reconnect restores it in place.

On MCP

Each operation is its own tool. The six reads (list_connectors / list_connections / get_connection / get_connection_status / list_connection_syncs / get_connection_summary) and the setup check (get_setup_status, see Check your setup) only read and change nothing. The four lifecycle writes (connect_source / disconnect_connection / update_connection / reconnect_connection) make changes, so a client may ask the user to approve those calls. Reads are also reachable as native MCP resources on the connection:// scheme (connection://catalog, connection://list, connection://<id>, connection://<id>/status, connection://<id>/runs, connection://<id>/summary) for clients that implement resources/*.

Scope gating is per tool: reads need connections:read (on every bundle including read-only), get_setup_status needs data:read, connect_source / update_connection / reconnect_connection need connections:write, and disconnect_connection needs connections:delete — which in practice means an OAuth-connected client can manage connectors (when its user approved those scopes, as the default request does) and an API-key client can watch them.

Sync cadence is not here

How often a connector type syncs is a platform-level operational setting, not a per-connection knob — there is no cadence field on any of these ops. If a source looks stale, read /runs first: a rate_limit or auth error_reason is the actual story far more often than cadence.