> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sente.run/llms.txt
> Use this file to discover all available pages before exploring further.

# Connect an account you own

> Delegate an account you already have to your agent in one call: Sente logs in, vaults the credentials write-only, keeps the login fresh, and lets you revoke it at any time.

Connect is the owner-authorized path. You already have an account at a third-party app — a vendor
portal, a dashboard, an internal tool — and you want an agent to operate it. You hand Sente that
account's credentials once. Sente logs in with a real browser, vaults the credentials **write-only**,
and keeps the login re-establishable so your agent can ask for a signed-in browser whenever it needs
one.

Same posture as giving Plaid your bank login or putting a shared credential in 1Password: you own
the account, you authorize the access, you revoke it in one call. Nothing about the target site is
circumvented — where the site insists on a human, [a human is paged](/runs/human-takeover).

Prefer this over [Register](/accounts/register) whenever the account can exist already. It is the
lower-risk path: no terms question about automated signup, no fraud wall, no CAPTCHA at the door.

<Warning>
  Connect only accounts you own or are authorized to operate. Where the app supports it, connect a
  scoped member or service account rather than an admin login.
</Warning>

## Before you start

Three limits decide whether an account can be connected at all:

* **SSO-only accounts cannot be connected.** If the account signs in through Google/Microsoft/Okta
  or a passkey, there is no username and password to vault, and nothing for Sente to re-login with.
* **SMS 2FA works, but never autonomously.** The code goes to a phone Sente does not have. Every
  login that triggers one blocks and pages a human.
* **Email 2FA needs a human too**, unless the account uses an authenticator app (pass the TOTP seed,
  below) or its notification email is repointed to the Sente address (see the accordion at the end
  of [Two-factor authentication](#two-factor-authentication)).

## Connect an account

`POST /v1/connections` with the app's login URL and the credentials. `identityId` is optional —
omit it and the backing email identity is provisioned from the app's hostname. The call returns the
connection and the [run](/runs/overview) that logs in.

<CodeGroup>
  ```bash CLI theme={null}
  sente connect https://app.mailtrap.io/signin \
    --username "ops@yourcompany.com" \
    --password "$MAILTRAP_PASSWORD" \
    --totp-seed "$MAILTRAP_TOTP_SEED"     # optional; base32 or an otpauth:// URI

  # The CLI waits for the run and exits non-zero unless it completed. If it blocked, it prints the
  # live-view URL and the resume command:
  #   sente run resume <runId>

  sente session open <connectionId>       # → a CDP url for a browser already signed in
  ```

  ```ts TypeScript theme={null}
  import { Sente } from "@sente-labs/sdk";
  import { chromium } from "playwright";

  const sente = new Sente({ apiKey: process.env.SENTE_API_TOKEN! });

  // No identityId — the backing email identity is provisioned for this app.
  const { connection, run } = await sente.connections.connect({
    appUrl: "https://app.mailtrap.io/signin",
    credentials: {
      username: "ops@yourcompany.com",
      password: process.env.MAILTRAP_PASSWORD!,
      totpSeed: process.env.MAILTRAP_TOTP_SEED,   // optional
    },
  });

  const settled = await sente.runs.waitForRun(run.id);   // timeout in ms, default 180000
  if (settled.status === "blocked") {
    // A person opens settled.liveViewUrl, does the one step, then:
    //   await sente.runs.resume(run.id);
    throw new Error(`${settled.error?.code}: a human is needed at ${settled.liveViewUrl}`);
  }
  if (settled.status !== "completed") throw new Error(`connect failed: ${settled.error?.code}`);

  // The account is live. Get a browser that is already signed in to it:
  const session = await sente.registrations.getSession(connection.id);
  const browser = await chromium.connectOverCDP(session.cdpUrl);
  try {
    const context = browser.contexts()[0] ?? (await browser.newContext());
    const page = context.pages()[0] ?? (await context.newPage());
    await page.goto("https://app.mailtrap.io/inboxes");
  } finally {
    await browser.close();
    await sente.registrations.closeSession(connection.id);
  }
  ```

  ```python Python theme={null}
  import os
  from sente import Sente
  from playwright.sync_api import sync_playwright

  sente = Sente(api_key=os.environ["SENTE_API_TOKEN"])

  # No identity_id — the backing email identity is provisioned for this app.
  connection, run = sente.connections.connect(
      app_url="https://app.mailtrap.io/signin",
      username="ops@yourcompany.com",
      password=os.environ["MAILTRAP_PASSWORD"],
      totp_seed=os.environ.get("MAILTRAP_TOTP_SEED"),   # optional
  )

  settled = sente.runs.wait_for_run(run.id)             # timeout in SECONDS, default 180
  if settled.status == "blocked":
      # A person opens settled.live_view_url, does the one step, then:
      #   sente.runs.resume(run.id)
      raise RuntimeError(f"{settled.error['code']}: a human is needed at {settled.live_view_url}")
  if settled.status != "completed":
      raise RuntimeError(f"connect failed: {settled.error}")

  session = sente.registrations.get_session(connection.id)
  with sync_playwright() as p:
      browser = p.chromium.connect_over_cdp(session["cdpUrl"])
      try:
          context = browser.contexts[0] if browser.contexts else browser.new_context()
          page = context.pages[0] if context.pages else context.new_page()
          page.goto("https://app.mailtrap.io/inboxes")
      finally:
          browser.close()
          sente.registrations.close_session(connection.id)
  ```
</CodeGroup>

On success the run ends `completed` and the connection's `status` is `active`. From then on it
behaves like any other account: `getSession`, `login`, `exportSession` all take the connection id.

## Lifecycle

<Steps>
  <Step title="Connect">
    You supply `username`, `password`, and optionally `totpSeed`. All three are encrypted with
    AES-256-GCM before they reach the database. A malformed seed is rejected here, at enrollment
    (`400 BAD_TOTP_SEED`) — not silently three re-logins later.
  </Step>

  <Step title="Sente logs in">
    A browser agent opens the login URL and signs in with your credentials in an isolated remote
    browser on a per-identity profile. The password is registered in a redaction vault first, so it
    is scrubbed from logs, step streams, and the activity feed.
  </Step>

  <Step title="Second factor, if any">
    An authenticator prompt is answered server-side from the vaulted seed. An email or SMS code
    blocks the run with `MFA_REQUIRED` and pages a human — see below.
  </Step>

  <Step title="Active">
    The run completes, the connection goes `active`, and the logged-in cookies persist on the
    identity's browser profile.
  </Step>

  <Step title="Stay logged in">
    Ask for a session later and Sente checks how recently the login was confirmed. Stale, and it
    re-logs in first — from the vault, plus a server-computed TOTP code if there's a seed — before
    handing back a browser. See [Sessions](/accounts/sessions).
  </Step>

  <Step title="Revoke or delete">
    Revoke stops all use and keeps the vault; delete additionally purges the stored password and
    seed.
  </Step>
</Steps>

## Two-factor authentication

What happens at the 2FA prompt depends entirely on where the second factor lands:

| Second factor                           | Behaviour                                                                                                                                  | Needs a human?                           |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------- |
| Authenticator app (TOTP), seed supplied | The server computes the RFC-6238 code and injects it. Capped at 3 attempts, so a wrong seed or clock skew fails loudly instead of looping. | No — including on every future re-login. |
| Authenticator app, no seed              | Run blocks `TOTP_REQUIRED`.                                                                                                                | Yes, every login.                        |
| Code emailed to *your* inbox            | Run blocks `MFA_REQUIRED` — that inbox isn't Sente's, so there is nothing to read.                                                         | Yes, unless repointed (below).           |
| Code sent by SMS                        | Run blocks `MFA_REQUIRED`. Sente has no phone channel.                                                                                     | Yes, every login.                        |

The TOTP seed is the base32 string behind a QR code's "can't scan it?" link, or the whole
`otpauth://` URI. Either form is accepted, normalised, and vaulted. **The seed never reaches any
model** — the driving agent stops and reports, the server computes the six digits, and only those
digits are sent onward.

<Warning>
  A blocked run holds for about **10 minutes**, then fails with `BLOCKED_TIMEOUT` and releases the
  browser. An emailed code has its own, often shorter, expiry. Have the account owner ready *before*
  you start the connect, and wire the [`run.blocked` webhook](/events/webhooks) or `sente watch` so
  the page reaches them immediately.
</Warning>

<Accordion title="Making email 2FA autonomous by repointing the notification address">
  If you repoint the account's notification email at the app to the Sente identity's address, the
  login code lands in Sente's own inbox and the run controller fetches and applies it without a human
  — the same mechanism that completes verification for registered accounts.

  Two things to know before relying on it:

  * You must assert it: pass `verifyToIdentity: true` in the `POST /v1/connections` body. It is
    **raw-API only today** — not exposed by the SDKs or the CLI.
  * It is an owner-authorized settings change *you* make at the app first. If nothing routes to the
    Sente address, the controller waits briefly, then falls back to blocking for a human. SMS codes
    never route here.
</Accordion>

## Credentials are write-only

Once vaulted, a connected account's credentials cannot be read back through the API — not by you,
not by your agent, not by anything holding your API key:

```bash theme={null}
GET /v1/registrations/reg_.../credentials
→ 403 { "error": "connected-account credentials are write-only", "code": "CREDENTIALS_WRITE_ONLY" }
```

Only the run controller decrypts them, at the moment a login run needs them. You can overwrite them;
you can purge them; you can never fetch them. (Accounts Sente *created* are the opposite — that
generated password is yours to read, and every read is audited. See
[Register](/accounts/register).)

### Rotating credentials

After changing the password at the app, get the vault back in sync — otherwise the next re-login
fails. Two ways:

<CodeGroup>
  ```bash CLI theme={null}
  # Re-connect with the same identity (rotates the vault and re-drives a login run).
  sente connect https://app.mailtrap.io/signin --identity <identityRefOrId> \
    --username "ops@yourcompany.com" --password "$NEW_PASSWORD"
  ```

  ```ts TypeScript theme={null}
  // Vault-only write — does NOT change the password at the app.
  await sente.registrations.setCredentials(connection.id, { password: newPassword });
  await sente.registrations.login(connection.id);   // verify the new credential works
  ```

  ```python Python theme={null}
  sente.registrations.set_credentials(connection.id, password=new_password)
  sente.registrations.login(connection.id)
  ```
</CodeGroup>

<Warning>
  Re-connecting **without** `identityId` does not rotate anything: it provisions a fresh identity and
  creates a *second* connection to the same app. Pass the identity, or use `setCredentials` on the
  connection id.
</Warning>

## Revoke vs. delete

<CodeGroup>
  ```bash CLI theme={null}
  sente connections                        # list what's connected
  sente connection revoke <connectionId>   # disable; vault kept
  sente connection delete <connectionId>   # revoke AND purge the password + TOTP seed
  ```

  ```ts TypeScript theme={null}
  await sente.connections.list();
  await sente.connections.revoke(connection.id);
  await sente.connections.delete(connection.id);
  ```

  ```python Python theme={null}
  sente.connections.list()
  sente.connections.revoke(connection.id)
  sente.connections.delete(connection.id)
  ```
</CodeGroup>

|                         | Revoke (`POST /v1/connections/:id/revoke`) | Delete (`DELETE /v1/connections/:id`)   |
| ----------------------- | ------------------------------------------ | --------------------------------------- |
| Status                  | `disabled`, `revokedAt` stamped            | `disabled`, `revokedAt` stamped         |
| Sessions and logins     | Refused with `409 REVOKED`                 | Refused with `409 REVOKED`              |
| Vaulted password + seed | Kept, unused                               | Purged                                  |
| Undo                    | `connect` again re-enables it              | `connect` again, with fresh credentials |
| Use it for              | Pausing an agent's access                  | Offboarding — Sente then holds nothing  |

<Note>
  Delete purges what Sente stores; it does not touch the account at the app. If you need certainty
  that the credential is dead, rotate the password there too.
</Note>

## Failure paths

Call-time errors:

| Status | Code                      | Means                                                        | Do                                                                        |
| ------ | ------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------- |
| `400`  | `BAD_TOTP_SEED`           | The seed isn't valid base32 or an `otpauth://` URI.          | Re-copy it from the app's "can't scan?" text.                             |
| `409`  | `ALREADY_REGISTERED`      | Sente *created* an account at that origin for this identity. | Use it via `login` / `getSession`, or connect under a different identity. |
| `409`  | `REVOKED`                 | Login or credential write against a revoked connection.      | `connect` again to re-enable.                                             |
| `403`  | `CREDENTIALS_WRITE_ONLY`  | Credential read on a connected account.                      | By design — you hold that password already.                               |
| `429`  | `RUN_LIMIT_EXCEEDED`      | Daily or monthly run cap. Failed runs never count.           | Wait for the window or upgrade ([Limits](/trust/limits)).                 |
| `429`  | `IDENTITY_LIMIT_EXCEEDED` | Auto-provisioning would exceed the identity cap.             | Pass an existing `identityId`, or upgrade.                                |

Run outcomes — a `blocked` run is paused, not failed; clear it in the live view and resume:

| `error.code`        | Means                                                          | Do                                                                          |
| ------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------- |
| `MFA_REQUIRED`      | An email or SMS code went to the account owner.                | Enter it in the live view, then resume.                                     |
| `TOTP_REQUIRED`     | Authenticator prompt with no vaulted seed.                     | Enter a code in the live view, then resume — or re-connect with `totpSeed`. |
| `CAPTCHA_REQUIRED`  | The app presented a CAPTCHA. Sente does not complete CAPTCHAs. | Solve it in the live view, then resume.                                     |
| `LOGIN_UNCONFIRMED` | The agent couldn't confirm it ended up signed in.              | Check in the live view; often a wrong password or an interstitial.          |
| `BLOCKED_TIMEOUT`   | Nobody resumed within the \~10-minute hold.                    | Retry the connect with a human standing by.                                 |
| `AUTOMATION_STUCK`  | The agent could not proceed and named no known gate.           | Inspect the run, then retry.                                                |

## Recommended practice

* **Connect a scoped account, not your admin login.** If the app has member roles, service accounts,
  or restricted API users, create one with the least privilege the agent needs and connect that.
* **Turn on TOTP where the app offers it.** It is the one second factor that keeps re-login fully
  autonomous.
* **Prune what you don't use.** `sente connections` / `connections.list()`, then revoke.
* **Wire `run.blocked` before you need it.** Re-logins happen on Sente's schedule, not yours; a
  connected account with email 2FA will eventually block at an inconvenient moment.

## Next steps

<CardGroup cols={2}>
  <Card title="Sessions" href="/accounts/sessions">
    Turn the connection into a logged-in browser — CDP or Playwright `storageState`.
  </Card>

  <Card title="Human takeover" href="/runs/human-takeover">
    The live view, the \~10-minute hold, and how a person clears a blocked login.
  </Card>

  <Card title="Security model" href="/trust/security">
    Where the vault key lives, what the browser agent sees, and what we don't have yet.
  </Card>

  <Card title="API reference: connections" href="/api-reference/connections">
    Request and response shapes for connect, list, revoke, delete.
  </Card>
</CardGroup>
