---
title: "App Connections"
description: "Connect Google Calendar, Sheets, Slack, HubSpot, Salesforce, and Cal.com, and control which tools each agent may use."
---

A **provider connection** links a third-party account to your org and
exposes a curated set of **agent tools** (e.g. "create a calendar
event", "post to Slack"). You control capability at two levels:

- **Connection-level `allowed_operations`** — which operation groups
  the whole connection permits (e.g. Slack `post: false`).
- **Per-agent attachments** — which agents may use the connection, and
  optionally a narrower per-agent operation override.

OAuth-based providers are connected from the dashboard (the browser
OAuth consent flow can't be driven by an API key). Once connected,
everything on this page is manageable via the API. Cal.com uses an API
key instead and can be connected [directly](#connect-with-an-api-key).

<Note>
  Authorization here is **operation-level, not record-level** — see
  [Authorization model](#authorization-model) below before assuming an
  agent's access can be scoped to specific channels, contacts, or
  calendars.
</Note>

## Providers and their tools

| Provider | `provider` | Auth | Operation groups (defaults) | Agent tools |
|----------|-----------|------|------------------------------|-------------|
| Google Calendar | `google_calendar` | OAuth | `view_events` ✓, `create_events` ✓, `update_events` ✗, `delete_events` ✗ | `calendar_list_events`, `calendar_check_availability`, `calendar_create_event`, `calendar_update_event`, `calendar_cancel_event` |
| Google Sheets | `google_sheets` | OAuth | `append_rows` ✓ | `sheets_append_row` (requires [sheet configuration](#configure-the-target-google-sheet)) |
| Slack | `slack` | OAuth | `view` ✓, `post` ✗, `search` ✓ | `slack_list_channels`, `slack_post_message`, `slack_search_messages` |
| HubSpot | `hubspot` | OAuth | `view` ✓, `write` ✗ | `hubspot_search_contacts`, `hubspot_get_contact`, `hubspot_upsert_contact`, `hubspot_add_note` |
| Salesforce | `salesforce` | OAuth | `view` ✓, `write` ✗ | `salesforce_search_records`, `salesforce_get_record`, `salesforce_upsert_lead`, `salesforce_log_activity` |
| Cal.com | `calcom` | API key | `view` ✓, `book` ✓, `manage` ✗ | `calcom_list_event_types`, `calcom_check_availability`, `calcom_create_booking`, `calcom_cancel_booking` |

Each tool maps to one operation group — disabling the group removes
every tool it gates from all attached agents.

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/v1/provider-connections` | List connections + provider catalog |
| `GET` | `/v1/provider-connections/{connection_id}` | Retrieve one connection |
| `PATCH` | `/v1/provider-connections/{connection_id}` | Update connection-level `allowed_operations` or `send_guest_invitations` |
| `DELETE` | `/v1/provider-connections/{connection_id}` | Disconnect |
| `POST` | `/v1/provider-connections/{provider}/api-key` | Connect an API-key provider (Cal.com) |
| `PUT` | `/v1/provider-connections/{connection_id}/agents/{agent_id}` | Attach an agent (optional per-agent override) |
| `DELETE` | `/v1/provider-connections/{connection_id}/agents/{agent_id}` | Detach an agent |
| `POST` | `/v1/provider-connections/{connection_id}/sheets/picker-token` | Mint a short-lived token for the Google Picker |
| `POST` | `/v1/provider-connections/{connection_id}/sheets/inspect` | List a picked spreadsheet's tabs |
| `POST` | `/v1/provider-connections/{connection_id}/sheets/configure` | Pick the target spreadsheet + sheet |

`{connection_id}` is the connection's **UUID**. All writes require the
`admin` role. (The browser-only OAuth start/callback routes are not
part of the public API.)

## Connection object

```json
{
  "id": "9e1f6a3c-…",
  "provider": "slack",
  "provider_account_email": "ops@acme.com",
  "provider_account_id": "T024BE7LD",
  "provider_metadata": { "team_name": "Acme" },
  "granted_scopes": ["channels:read", "channels:history", "groups:read", "groups:history"],
  "allowed_operations": { "view": true, "post": true, "search": true },
  "effective_operations": { "view": true, "post": false, "search": true },
  "token_expires_at": null,
  "created_at": "2026-04-20T18:24:10.113Z",
  "updated_at": "2026-04-20T18:24:10.113Z",
  "agent_attachments": [
    {
      "agent_id": 12,
      "agent_name": "Customer Support Agent",
      "allowed_operations": null,
      "tool_names": ["slack_list_channels", "slack_post_message", "slack_search_messages"]
    }
  ]
}
```

| Field | Type | Description |
|-------|------|-------------|
| `id` | UUID | Connection id |
| `provider` | string | Provider key (see table above) |
| `provider_account_email` / `provider_account_id` | string | Identity of the connected account |
| `provider_metadata` | object | Provider-specific info (e.g. the configured spreadsheet for Sheets) |
| `granted_scopes` | array of string | OAuth scopes actually granted |
| `allowed_operations` | object | Connection-level operation toggles as configured, e.g. `{"view": true, "post": false}`. This is the raw ceiling — it does **not** reflect whether the OAuth grant covers each operation's required scopes |
| `send_guest_invitations` | boolean | Google Calendar only. When `true` (the default), event creates, updates, and cancellations ask Google to email the guests (`sendUpdates=all`). Other providers carry the field but ignore it |
| `effective_operations` | object | Read-only, additive field. The same toggles after intersecting with `granted_scopes` — i.e. what's actually enforced. An operation can show `true` in `allowed_operations` but `false` here if the grant is missing a required scope (re-consent narrowed, a scope was added to an operation after the connection was created, etc.). Use this field, not `allowed_operations`, to know what an agent can really do |
| `token_expires_at` | timestamp \| null | Access-token expiry (refreshed automatically) |
| `agent_attachments` | array | Attached agents. `allowed_operations: null` means "inherit the connection's"; `tool_names` is the resulting tool list — connection `allowed_operations`, the per-agent override, and `granted_scopes` all intersected |

Tokens and API keys are **never** returned by any endpoint.

---

## List connections

<CodeGroup>
```bash cURL
curl https://api.thunderphone.com/v1/provider-connections \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
</CodeGroup>

```json Response
{
  "configured": true,
  "providers": {
    "slack": {
      "label": "Slack",
      "configured": true,
      "auth_kind": "oauth",
      "default_operations": { "view": true, "post": false, "search": true }
    }
  },
  "connections": [ /* Connection objects */ ],
  "agents": [{ "id": 12, "name": "Customer Support Agent" }]
}
```

`providers` is the full catalog with per-provider availability
(`configured` is false when the ThunderPhone deployment lacks that
provider's OAuth app). `agents` is a convenience list for building
attachment UIs.

---

## Update allowed operations

<CodeGroup>
```bash cURL
curl -X PATCH https://api.thunderphone.com/v1/provider-connections/9e1f6a3c-… \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"allowed_operations": {"view": true, "post": true, "search": true}}'
```
</CodeGroup>

`allowed_operations` must be an object whose keys are that provider's
operation groups (unknown keys are rejected with `400`). The body may also
carry `send_guest_invitations` (boolean, Google Calendar only) on its own or
alongside `allowed_operations`; a body with neither is rejected with `400`.
Returns the updated [connection object](#connection-object).

<Warning>
  Enabling an operation the OAuth grant doesn't cover (e.g. turning on
  Slack `post` when `chat:write` wasn't granted) makes the tool fail at
  call time — reconnect from the dashboard to grant the extra scopes.
</Warning>

## Authorization model

There are three layers, evaluated together at tool-synthesis and
tool-execution time:

1. **Connection-level `allowed_operations`** — the ceiling for the
   whole connection, set via [PATCH](#update-allowed-operations).
2. **Per-agent override** (`agent_attachments[].allowed_operations`)
   — an optional narrower subset for one agent, set via
   [attach/detach](#attach-detach-agents). `null` means "inherit the
   connection's ceiling" — an override can only turn operations
   *off*, never grant one the connection ceiling denies.
3. **`granted_scopes`** — what the OAuth grant actually covers. Each
   operation requires specific scopes (e.g. Slack `post` requires
   `chat:write`); an operation enabled at both the connection and
   agent level still can't run if the grant doesn't cover it.

`effective_operations` on the [connection object](#connection-object)
is operations (1) and (3) intersected — it does **not** factor in a
per-agent override, since that's per-attachment. The `tool_names` on
each `agent_attachments` entry is the fully-intersected result: connection
ceiling ∩ per-agent override (if any) ∩ granted scopes. That list is
what the agent's tool schema actually contains, and `execute_provider_tool`
re-checks the same intersection at call time (defense in depth against a
stale route or shared connection dispatching a call the agent's current
config would no longer allow).

<Warning>
  **Authorization is provider-level, not per-record.** Toggling an
  operation on grants (or denies) it across every record the OAuth
  scope reaches — there is no per-channel, per-contact, per-object, or
  per-calendar scoping. Concretely:

  - An agent with Slack `post: true` can post to **any** channel the
    connected bot/user can reach — not a chosen subset of channels.
  - An agent with Slack `view` / `search` can read **any** channel the
    grant reaches, same caveat.
  - An agent with HubSpot or Salesforce `write: true` can create or
    update **any** record type/object the OAuth grant covers, not a
    restricted list of pipelines or object types.
  - Google Calendar operations always act on the connected account's
    **primary calendar** — there's no way to scope an agent to a
    secondary or shared calendar.

  If you need to isolate what one agent can touch within a provider
  (e.g. "this agent may only post in `#support`"), connect a
  **separate provider account** scoped to just that resource (a
  dedicated Slack app/bot invited to one channel, a HubSpot user with
  restricted CRM permissions, a secondary Google account whose primary
  calendar is the one you want exposed) and attach that connection
  instead. Per-resource ACLs on a single connection (e.g. an
  allow-list of Slack channel IDs) are **not implemented** — this has
  come up before as a half-built field with no way to populate it, and
  is tracked as a roadmap item rather than something to configure
  today.
</Warning>

## Disconnect

`DELETE /v1/provider-connections/{id}` returns `204 No Content` and
removes the connection and every agent attachment.

---

## Connect with an API key

Only for `auth_kind: "api_key"` providers — currently **Cal.com**.

<CodeGroup>
```bash cURL
curl -X POST https://api.thunderphone.com/v1/provider-connections/calcom/api-key \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"api_key": "cal_live_…"}'
```
</CodeGroup>

The key is validated against the provider before saving; invalid keys
return `400` with an `api_key` field error. Returns `201 Created` with
the [connection object](#connection-object).

---

## Attach / detach agents

Attaching gives the agent the connection's tools on its next deploy
of call config (tool lists are computed per call).

<CodeGroup>
```bash Attach (inherit connection ops)
curl -X PUT https://api.thunderphone.com/v1/provider-connections/9e1f6a3c-…/agents/12 \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'
```

```bash Attach (narrower override)
curl -X PUT https://api.thunderphone.com/v1/provider-connections/9e1f6a3c-…/agents/12 \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"allowed_operations": {"view": true, "post": false, "search": false}}'
```
</CodeGroup>

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `allowed_operations` | object \| null | no | Per-agent override. Omit / `null` to inherit the connection-level toggles |

`PUT` is an upsert — repeat it to change the override. Returns the
[connection object](#connection-object) with updated
`agent_attachments`. `DELETE` on the same path detaches the agent
(`204`).

---

## Configure the target Google Sheet

`sheets_append_row` writes to one configured spreadsheet + sheet per
connection. ThunderPhone holds the per-file `drive.file` Google scope,
so the connection can only open spreadsheets the user has explicitly
granted by picking them in the Google Picker (the dashboard runs this
flow). Setup is three steps (Sheets connections only — other providers
return `404` on these routes):

### Mint a Picker token

<CodeGroup>
```bash cURL
curl -X POST https://api.thunderphone.com/v1/provider-connections/9e1f6a3c-…/sheets/picker-token \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
</CodeGroup>

```json Response
{ "access_token": "ya29.…" }
```

Returns a short-lived access token for the connection's own Google
credential. Load the [Google Picker](https://developers.google.com/drive/picker/guides/overview)
with this token (`setOAuthToken`) so the user's pick grants file access
to the same credential the agent's tools use at call time.

### Inspect a spreadsheet

<CodeGroup>
```bash cURL
curl -X POST https://api.thunderphone.com/v1/provider-connections/9e1f6a3c-…/sheets/inspect \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"spreadsheet_id": "1AbC…"}'
```
</CodeGroup>

```json Response
{
  "spreadsheet_id": "1AbC…",
  "title": "Lead intake",
  "sheets": ["Leads", "Archive"]
}
```

Takes the file id returned by the Picker and lists the tab names.
`400` when the spreadsheet has not been granted through the Picker (or
access was revoked).

### Configure

<CodeGroup>
```bash cURL
curl -X POST https://api.thunderphone.com/v1/provider-connections/9e1f6a3c-…/sheets/configure \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"spreadsheet_id": "1AbC…", "sheet_name": "Leads"}'
```
</CodeGroup>

Reads row 1 of the chosen sheet as the column headers (at least one
non-empty header required — `400` otherwise) and stores
`spreadsheet_id`, `sheet_name`, and the header map in
`provider_metadata`. Returns the updated
[connection object](#connection-object). The agent tool then appends
one row per call, mapping its arguments onto those headers.

---

## Related

<CardGroup cols={2}>
  <Card title="Agents" icon="robot" href="/api-reference/agents">
    Attached tools appear alongside the agent's other tools.
  </Card>
  <Card title="MCP Servers" icon="server" href="/api-reference/mcp-servers">
    Bring arbitrary tools via the Model Context Protocol.
  </Card>
  <Card title="Integrations" icon="plug" href="/api-reference/integrations">
    Wrap any HTTP API as an agent tool.
  </Card>
  <Card title="Calls" icon="phone" href="/api-reference/calls">
    Tool invocations show up in call history.
  </Card>
</CardGroup>
