> ## 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.

# Accounts

> The one object you work with: an account at a third-party app. Learn what a call returns, how the email identity behind it appears by itself, and when to pass one explicitly.

The object you work with is an **account** — your agent's login at one third-party app. You get one
with a single call:

* [Connect](/accounts/connect) an account you already own (`POST /v1/connections`), or
* [Register](/accounts/register) a new one where the app's terms permit it (`POST /v1/registrations`).

Both return the same object plus the [run](/runs/overview) that logs in. Everything else — the
credential vault, the logged-in browser session, the mailbox that receives verification codes —
hangs off that account.

The email address is **plumbing**. You don't create one first; omit `identityId` and Sente
provisions the backing identity for you.

## One call

<CodeGroup>
  ```bash CLI theme={null}
  sente connect https://app.mailtrap.io/signin \
    --username "ops@yourcompany.com" \
    --password "$MAILTRAP_PASSWORD"
  # waits for the run, prints the connection id and status (exit 1 if it did not complete)

  sente connections          # → appUrl  status  run:completed  reg_…
  sente identity list        # → the address that was provisioned for it
  ```

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

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

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

  const settled = await sente.runs.waitForRun(run.id);      // timeout is in MILLISECONDS
  if (settled.status === "blocked") throw new Error(`needs a human: ${settled.liveViewUrl}`);

  // The identity that appeared by itself:
  const identity = await sente.identities.get(connection.identityId);
  console.log(connection.id, connection.kind, identity.email);
  // reg_…  connected  app-mailtrap@sente.run
  ```

  ```python Python theme={null}
  import os
  from sente import Sente

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

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

  settled = sente.runs.wait_for_run(run.id)                 # timeout is in SECONDS
  if settled.status == "blocked":
      raise RuntimeError(f"needs a human: {settled.live_view_url}")

  identity = sente.identities.get(connection.identity_id)
  print(connection.id, connection.kind, identity.email)
  # reg_…  connected  app-mailtrap@sente.run
  ```
</CodeGroup>

## The account

The API returns the same projection everywhere (`POST /v1/connections` calls it `connection`,
`POST /v1/registrations` calls it `registration` — same object, same id space, `reg_…`):

| Field        | Meaning                                                                                |
| ------------ | -------------------------------------------------------------------------------------- |
| `id`         | `reg_…`. The handle for credentials, sessions, re-login, revoke.                       |
| `identityId` | The email identity behind it — passed by you, or provisioned for you.                  |
| `appUrl`     | The URL you supplied, kept verbatim for display.                                       |
| `kind`       | `created` (Sente signed up) or `connected` (you own it, you supplied the credentials). |
| `username`   | The login username. `null` on a `created` account until the run picks one.             |
| `status`     | `pending` → `active` on the first completed run; `failed`; `disabled` once revoked.    |
| `revokedAt`  | Timestamp, set when a connection is revoked. `null` otherwise.                         |
| `createdAt`  | When the row was created.                                                              |

`kind` is the whole security boundary between the two paths:

|                      | `created`                                          | `connected`                                   |
| -------------------- | -------------------------------------------------- | --------------------------------------------- |
| Who owns the account | Sente made it under the identity's address         | You did, before Sente existed                 |
| Credentials          | Readable — `GET /v1/registrations/:id/credentials` | **Write-only** — `403 CREDENTIALS_WRITE_ONLY` |
| TOTP seed            | Not supported                                      | Optional, vaulted, never readable             |
| Revocable            | No (delete the identity to remove it)              | Yes — revoke, or delete to purge the vault    |

The two never co-mingle at the same app. `register` over a connected account returns
`409 ALREADY_CONNECTED`; `connect` over a created one returns `409 ALREADY_REGISTERED`.

## The identity is plumbing

An identity is a real mailbox on `sente.run`. It exists so verification email has somewhere to
land — and, for a `created` account, so the app has an address to sign up with. When you omit
`identityId`, Sente derives the local part from the app's hostname:

| `appUrl`                                               | Address provisioned      | Identity `name`   |
| ------------------------------------------------------ | ------------------------ | ----------------- |
| `https://github.com/signup`                            | `github@sente.run`       | `github.com`      |
| `https://app.mailtrap.io/signin`                       | `app-mailtrap@sente.run` | `app.mailtrap.io` |
| `https://github.com/signup` (local part already taken) | `github-x7k2@sente.run`  | `github.com`      |

Addresses are globally unique, so a taken local part gets a random suffix rather than an error. If
the create then fails, the auto-provisioned identity is rolled back, so a failed connect doesn't eat
your plan's identity cap.

<Warning>
  Auto-provisioning is **per call**, not per app. Two `connect` calls for the same app with no
  `identityId` produce two identities and two separate accounts — and consume two identity slots.
  Keep the returned `id` (or pass `identityId`) when you mean "the account I already made".
</Warning>

## One account per (identity, app origin)

The uniqueness key is the identity plus the app's **origin** — scheme, host and port, lowercased,
path and query stripped.

* `https://app.example.com/signup` and `https://app.example.com/` are the same account.
* `https://example.com` and `https://app.example.com` are **different** accounts.

That makes the create calls idempotent for one identity:

| Situation                                   | What happens                                                                        |
| ------------------------------------------- | ----------------------------------------------------------------------------------- |
| No account at that origin                   | Creates the row, queues a `register`/`connect` run.                                 |
| A run is already in flight for the identity | Returns the existing account and **that** run — no second run.                      |
| Account is `active`, no live run            | `register` returns it and its last run and drives nothing (use `login` to re-auth). |
| Account is `pending`/`failed`, no live run  | Queues a fresh run on the same row (a retry).                                       |
| An existing **connection**                  | `connect` rotates the credentials, clears any revoke, re-drives.                    |

<Note>
  Runs and sessions serialize per identity: one run at a time, one live browser session. Ten accounts
  under ten identities work in parallel; ten under one identity queue behind each other.
</Note>

## When to pass `identityId`

| You want                                                       | Do                                                                           |
| -------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| An account, fast, and don't care about the address             | Omit it.                                                                     |
| Several accounts to share one mailbox and one address          | Create an identity once, pass its `id` every time.                           |
| A specific address (you told a vendor `billing-bot@sente.run`) | `identities.create({ localPart })`, then pass the `id`.                      |
| Maximum parallelism across apps                                | Omit it — one identity per account means runs never queue behind each other. |
| To rotate credentials on an existing connection                | Pass the same `identityId` (or use `setCredentials` on the account id).      |

Creating one explicitly is a normal call:

<CodeGroup>
  ```bash CLI theme={null}
  sente identity create --name "billing-bot" --local-part billing-bot
  # add --on-conflict suffix to auto-suffix instead of failing when the local part is taken
  ```

  ```ts TypeScript theme={null}
  const identity = await sente.identities.create({ name: "billing-bot", localPart: "billing-bot" });
  await sente.connections.connect({ identityId: identity.id, appUrl, credentials });
  ```

  ```python Python theme={null}
  identity = sente.identities.create(name="billing-bot", local_part="billing-bot")
  sente.connections.connect(identity_id=identity.id, app_url=app_url, username=u, password=p)
  ```
</CodeGroup>

## What is stored where

| Secret / state                            | Lives on                       | Readable through the API                                    |
| ----------------------------------------- | ------------------------------ | ----------------------------------------------------------- |
| Password                                  | The account, AES-256-GCM       | `created`: yes (audited). `connected`: never.               |
| TOTP seed                                 | The account, AES-256-GCM       | Never. Only the server computes codes from it.              |
| Logged-in cookies / storage               | The identity's browser profile | Via [Sessions](/accounts/sessions) — CDP or `storageState`. |
| Verification email, and every later email | The identity's inbox           | Via the [messages API](/email/inbox).                       |

One consequence worth planning around: **every app under one identity emails the same address.**
That is what makes verification work without per-app inbox plumbing — and why an identity shared
across ten apps has one busy mailbox.

## Failure paths

| Status | Code                      | Means                                                   | Do                                                                               |
| ------ | ------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------- |
| `409`  | `ALREADY_CONNECTED`       | You called `register` where a connection exists.        | Use the connection; it's the owner's account.                                    |
| `409`  | `ALREADY_REGISTERED`      | You called `connect` where Sente created the account.   | Use `login` / `getSession` on that id.                                           |
| `409`  | `REVOKED`                 | The connection was revoked.                             | `connect` again to re-enable, or leave it off.                                   |
| `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.                                    |
| `403`  | `CREDENTIALS_WRITE_ONLY`  | Credential read on a `connected` account.               | By design — you already hold that password.                                      |
| `404`  | —                         | `identityId` doesn't exist in your org.                 | Check the id, or omit it.                                                        |
| `429`  | `IDENTITY_LIMIT_EXCEEDED` | Auto-provisioning would exceed the plan's identity cap. | Reuse an identity, delete unused ones (`DELETE /v1/identities/:id`), or upgrade. |
| `429`  | `RUN_LIMIT_EXCEEDED`      | Daily or monthly run cap. Failed runs never count.      | Wait for the window, or upgrade. See [Limits](/trust/limits).                    |

## Next steps

<CardGroup cols={2}>
  <Card title="Connect an account you own" href="/accounts/connect">
    The owner-authorized path: credentials in write-only, TOTP re-login, revoke at any time.
  </Card>

  <Card title="Register a new account" href="/accounts/register">
    Where terms permit — including what reliably doesn't work.
  </Card>

  <Card title="Sessions" href="/accounts/sessions">
    Turn an account into a logged-in browser: CDP URL or Playwright `storageState`.
  </Card>

  <Card title="Runs" href="/runs/overview">
    The job that does the logging in: lifecycle, statuses, error codes.
  </Card>
</CardGroup>
