URL: /code/integrations/calls/connect · Id: `page:/code/integrations/calls/connect`

# Integration.connect

> Connect an account you already own, approving only the grants its jobs need.

`POST https://api.sb/{your-domain}/integrations/connect`

Connect an account you already own, approving only the grants its jobs need.

- **Action class:** None
- **Approval:** Runs on its own
- **Emits:** `Integration.connected`
- **Sites:** [1 sites](#on-each-venue)
- **Price:** Free

```text title="Run it · Ask"
Connect my cold inbox, dana@tryleaseline.app, so my agents can send from it and read the replies.
```

```ts title="Run it · SDK"
import { $ } from 'startup-builder'

const result = await $.try.Integration.connect({ system: 'mailboxes', scope: 'startup', grants: ['send', 'read-replies'], venue: 'gmail', for: 'dana@leaseline.app' })
```

```json title="Run it · Response"
{
  "api": "https://api.sb/leaseline.app",
  "integration": {
    "$type": "Integration",
    "system": "mailboxes",
    "scope": "startup",
    "venue": "gmail",
    "state": "connecting",
    "grants": [
      "send",
      "read-replies"
    ],
    "account": null,
    "environment": "live"
  },
  "connectUrl": "https://accounts.google.com/o/oauth2/v2/auth?state=try_int_Gm4cQ7wT2x",
  "action": {
    "operation": "Integration.connect",
    "mode": "try",
    "status": "done"
  }
}
```

Connects an account you already own, with only the grants its jobs need, through the site’s own sign-in. [The hub’s definition of `Integration.connect`](https://startupbuilder.dev/business/nouns/integration/connect).</HubDefinition>

## Inputs
| Name | Type | Default | Meaning |
| --- | --- | --- | --- |
| `system` | `System` | Required | The System the Integration serves. |
| `scope` | `string` | Required | One of `studio`, `startup` or `customer`; a person’s own account is `startup`, narrowed to them. |
| `grants` | `string[]` | Required | The access requested, shown to the account holder before they approve. |
| `venue` | `string` | `null` | The id of the ad, social or app-store venue the account is on. Unset for an account that is not on one, such as a CRM or a calendar. |
| `for` | `Party` | `null` | The account holder, when an Agent asks on their behalf. Unset: the caller is the account holder. |

## Outputs
| Field | Type | What `null` means |
| --- | --- | --- |
| `integration` | `Integration` | Never null. |
| `connectUrl` | `url` | Never null while the Integration reads `connecting`. |
| `integration.state` | `string` | Never null: `connecting` until the account holder approves, then `connected`. |
| `integration.account` | `string` | Unknown until the account holder signs in, so always null in try mode and while `connecting`. |
| `integration.grants` | `string[]` | Never null: the grants asked for, narrowed to what the account holder approved. |
| `integration.environment` | `string` | Never null: `test` under a `sk_test_` key, `live` otherwise. |

## Effects
**Action class**

None: no approval rule names it, so it runs within its seat’s Permissions and Limits

**Modes**

- `try`: Checks each grant against the Verbs of the System and returns the Integration it would open; nothing is stored and the link opens nothing.
- `send`: Returns the `connecting` Integration and its `connectUrl` at once; `Integration.connected` fires when the account holder approves.
- `do`: Holds the call for the Mode’s hold time, then returns `202` with the pending Action if nobody has approved yet.

**Idempotency**

Only with a key: the same `Idempotency-Key` and input return the first Action, and the same key with other input returns `409`. Connecting an account that already holds a `connected` Integration for the same System returns that Integration, with any new grants added to it.

**Saved or shared**

The Integration records `connectedBy`, the outside account and the approved grants. The token is never in a response, an Event or a log, and nothing is shared with other startups.

**Emits**

`Integration.connected` with `{ integration, system, venue, account, grants, connectedBy }`

**Performed by**

[Integration Engineer](https://startupbuilder.dev/agents/engineering/integration-engineer)

**Price**

Free on your own account: the platform charges nothing for the call.

## On each Venue
| Site | Fields | Formats |
| --- | --- | --- |
| [The CRM you already use](#your-crm) | None. | None. |

### The CRM you already use
Through [The CRM you already use](/code/integrations/your-crm).

- **Direction.** One per Integration. Your records stay the record in both modes, and the side you declared leads every conflict.

## What try does
| Account | What try does |
| --- | --- |
| [The CRM you already use](/code/integrations/your-crm#try) | Reads your CRM and returns what one sync would change on each side, record by record; nothing is written to either. |

## Refuses when
This call has no refusals of its own. Every call through an account can also meet [the shared account refusals](/code/refusals#accounts): [`grant-missing`](/code/refusals#grant-missing), [`needs-reauth`](/code/refusals#needs-reauth), [`account-not-connected`](/code/refusals#account-not-connected), [`over-mandate`](/code/refusals#over-mandate), [`quota-reached`](/code/refusals#quota-reached), [`venue-rejected`](/code/refusals#venue-rejected).

## When it waits
| Fact | Value |
| --- | --- |
| `send` returns | A pending Action, `reason: 'awaitingInput'`, holding the `connectUrl` the account holder opens. |
| Delivered by | `Integration.connected` |

## Example

**Ask**

```text
Connect my cold inbox, dana@tryleaseline.app, so my agents can send from
it and read the replies.
```

**API**

```bash
curl -X POST "https://api.sb/leaseline.app/integrations/connect?mode=try" \
  -H "Authorization: Bearer $SB_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"system":"mailboxes","scope":"startup","grants":["send","read-replies"],"venue":"gmail","for":"dana@leaseline.app"}'
```

The response is the one in the Run it window above.

**CLI**

```bash
npx startup-builder do Integration.connect --try --data '{"system":"mailboxes","scope":"startup","grants":["send","read-replies"],"venue":"gmail","for":"dana@leaseline.app"}'
```

**MCP**

```json
{
  "name": "do",
  "arguments": {
    "mode": "try",
    "code": "return await $.Integration.connect({ system: 'mailboxes', scope: 'startup', grants: ['send', 'read-replies'], venue: 'gmail', for: 'dana@leaseline.app' })"
  }
}
```

**SDK**

```ts
import { $ } from 'startup-builder'

const result = await $.try.Integration.connect({
  system: 'mailboxes',
  scope: 'startup',
  grants: ['send', 'read-replies'],
  venue: 'gmail',
  for: 'dana@leaseline.app',
})
```

To retry, send the same `Idempotency-Key`: the retry returns the first answer for $0. A new key reads again.

A `sk_test_` key answers these the same way every time, at $0 ([every test value](/code/data#test-values)).

| Test value | Always returns | What it is for |
| --- | --- | --- |
| `venue: 'gmail' under sk_test_` | `{"state":"connected","environment":"test","account":"inbox@leaseline.test"}` | Skips the approval page and returns a connected test Integration that reaches no real mailbox, so the calls after it run end to end. |
| `maxCost: 0.0001` | Refuses with [`max-cost`](/code/refusals#max-cost) | Any priced call with a maxCost of 0.0001 is refused before it spends: the price is above the cap. |
| `rate-limited.test` | Refuses with [`rate-limited`](/code/refusals#rate-limited) | Any call naming this hostname answers 429 with Retry-After, so your retry logic runs. |
| `sources-busy.test` | Refuses with [`sources-busy`](/code/refusals#sources-busy) | Any call naming this hostname answers 503 as if every route were busy, and nothing is spent. |

## Used by
- **Jobs:** [Measure](/capabilities/measure) · [Run ads](/capabilities/run-ads)
- **Integrations:** [The CRM you already use](/code/integrations/your-crm)

## Related
[`Integration.call`](/code/integrations/calls/call) · [`Handle.connect`](/code/integrations/calls/connect-handle) · [`Mandate.authorize`](/code/integrations/calls/authorize)