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

# Python SDK

> The sente-sdk client — stdlib-only, import sente, talks to https://api.sente.run/v1.

`sente-sdk` is a thin Python client for the Sente REST API. Stdlib only (`urllib`) — no
dependencies. The PyPI distribution is `sente-sdk`; the import is `sente`.

```bash theme={null}
pip install sente-sdk
```

## Setup

Get an API key from the [dashboard](https://app.sente.run) (API keys page), or via the
[CLI](/sdks/cli) with `sente login` + `sente token`. The key authenticates your whole
organization — keep it in an environment variable, never in code.

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

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

`Sente(api_key, base_url=...)` — `base_url` defaults to `https://api.sente.run`.

## Create an identity

An identity is a real, reusable email address on `sente.run` that your agent owns.
See [Identities and registrations](/concepts/identities-and-registrations).

```python theme={null}
idt = sente.identities.create(name="support-bot", local_part="support-bot")
# -> Identity(id=..., email="support-bot@sente.run", ...)
```

If the `local_part` is taken, `create` raises a 409 `SenteError` (`on_conflict="error"` is
the default); pass `on_conflict="suffix"` to append a random suffix instead.

## Wait for a verification code

Every inbound email is classified server-side (`message.annotation`:
`kind` / `code` / `link` / `confidence`), so an agent that signed up somewhere with the
identity's address can block until the code or link arrives. Stamp `since` **before**
triggering the action so you never match an older email:

```python theme={null}
from datetime import datetime, timezone

since = datetime.now(timezone.utc).isoformat()
# ...trigger the signup / login that sends the verification email...
r = sente.messages.wait_for_otp(idt.id, since=since, timeout=60)
if r:
    print(r.code)  # e.g. "481923"; r is OtpResult(code, message)

# Links instead of codes:
ml = sente.messages.wait_for_magic_link(idt.id, since=since, timeout=60)
if ml:
    print(ml.link)  # MagicLinkResult(link, message)
```

Both return `None` on timeout. `timeout` is in seconds (1–60 per call, default 25). If you
omit `since`, the server falls back to a 60-second lookback. More patterns in
[Receive verification codes](/guides/receive-verification-codes).

## Send and reply

```python theme={null}
sente.messages.send(idt.id, to="user@example.com", subject="hi", text="from your agent")

# Thread a reply to an inbound message (sets In-Reply-To/References):
sente.messages.send(
    idt.id,
    to="user@example.com",
    subject="Re: hi",
    text="following up",
    in_reply_to=inbound_message_id,
)
```

`text` and/or `html` is required. To consume mail continuously,
`sente.messages.stream(identity_id)` is a generator that long-polls; for production prefer
[webhooks](/guides/webhooks) (push, not poll).

## Register at a third-party app

`registrations.register` drives a browser through signup at `app_url` under the identity's
email and completes email verification from the identity's own inbox. It returns
`RegisterResult(registration, run)`.

```python theme={null}
result = sente.registrations.register(
    identity_id=idt.id,
    app_url="https://app.example.com/signup",
    confirm_before_submit=True,  # a human clicks the final submit (recommended)
)
run = sente.runs.wait_for_run(result.run.id)  # settles on completed | failed | blocked
```

<Warning>
  Register only at apps whose terms permit it. `confirm_before_submit=True` keeps a human in
  the loop: the agent fills the form, then the run goes `blocked` with error code
  `SUBMIT_CONFIRMATION_REQUIRED` and a `live_view_url` — a person clicks the final submit
  there (and accepts the app's terms themselves), then you call `runs.resume(run.id)`. See
  [Acceptable use](/trust/acceptable-use).
</Warning>

`wait_for_run(run_id, timeout=180, poll_ms=2000)` polls until the run is `completed`,
`failed`, or `blocked` (`timeout` in **seconds**, default 180 — note the TypeScript SDK
takes milliseconds). `blocked` means human takeover: CAPTCHAs, SMS verification, and
confirm-before-submit all stop the run — a person finishes the step in the interactive
`live_view_url`, then `runs.resume(run_id)` continues. On timeout it returns the
last-polled `Run`. See [Runs and human takeover](/concepts/runs-and-human-takeover).

Re-authenticate later with `registrations.login(registration_id)` — same `RegisterResult`
shape. Vaulted credentials: `registrations.get_credentials(registration_id)` returns
`{ "username", "password", "origin" }`; `set_credentials(registration_id, username=...,
password=...)` overwrites the vault only — it does not change the password at the app.

## Connect an account you already own

`connections.connect` is delegated access to an existing account: you supply the
credentials, Sente logs in, vaults them **write-only**, and keeps the account
re-loginable. See the [Connect guide](/guides/connect).

```python theme={null}
result = sente.connections.connect(
    identity_id=idt.id,
    app_url="https://app.example.com/login",
    username="you@yourco.com",
    password=os.environ["APP_PASSWORD"],
    totp_seed=os.environ.get("APP_TOTP_SEED"),  # optional: base32 secret or otpauth:// URI
)
run = sente.runs.wait_for_run(result.run.id)  # result is ConnectResult(connection, run)
```

<Note>
  Limits, stated plainly:

  * **SSO-only accounts** (Google/Okta sign-in, passkeys) cannot be connected — connect needs a username + password login.
  * **Credentials are write-only**: `registrations.get_credentials` on a connected account raises 403 `CREDENTIALS_WRITE_ONLY`.
  * With a vaulted `totp_seed`, re-login clears TOTP 2FA without a human; the server computes the 30-second code — the seed never reaches any model.
  * **Email or SMS code MFA needs a human**: the run goes `blocked` with `MFA_REQUIRED` — enter the code in `live_view_url`, then `runs.resume(run.id)` — unless the account's notification email has been repointed to the Sente identity.
</Note>

`connections.revoke(connection_id)` disables the connection but keeps the vault (a later
`connect` re-enables it); `connections.delete(connection_id)` revokes **and** purges the
vaulted credentials. Connected accounts are also reachable through the `registrations`
methods (`get_session`, `login`, `export_session`) by the returned id.

## Open or export a logged-in session

`get_session` returns a remote browser already logged into the account, as a CDP URL you
drive yourself. If the login has gone stale, Sente re-logs-in first (vaulted credentials +
a fresh code from the identity's inbox) — `healed` is `True` when that happened, and a cold
call can take a minute.

```python theme={null}
from playwright.sync_api import sync_playwright

s = sente.registrations.get_session(registration_id)
# {"sessionId", "cdpUrl", "liveViewUrl", "expiresAt", "verified", "healed"}
with sync_playwright() as p:
    browser = p.chromium.connect_over_cdp(s["cdpUrl"])
    # ... drive it ...
sente.registrations.close_session(registration_id)
```

One live session per identity; open sessions count against your plan's browser-minute
allowance — always `close_session` when done. `get_session` accepts `verify=` (default `True`; `False` skips
the freshness check), `max_stale_sec=`, and `timeout_ms=` (default 240000). If re-login
can't recover, it raises `SenteError` (inspect the body's run).
`open_session(registration_id)` is the raw primitive with no freshness check.

To use your **own** browser stack instead, export the logged-in state (cookies +
localStorage) as a Playwright `storage_state` dict:

```python theme={null}
storage_state = sente.registrations.export_session(registration_id)
context = browser.new_context(storage_state=storage_state)
```

The export is a bearer credential for a logged-in account — store it like a secret. More
in the [Sessions guide](/guides/sessions).

## Webhooks

Register your endpoint and Sente pushes events to it with retries. The `secret` is only
present on the `Webhook` returned at register time — store it.

```python theme={null}
w = sente.webhooks.register(
    url="https://your-host.example.com/sente",
    events=["message.received"],
    identity_id=idt.id,  # omit for org-wide
)
print(w.secret)
```

`message.received` payloads are thin notifications with no body — fetch content with
`sente.messages.get(message_id)`. Run lifecycle events (`run.blocked`, `run.completed`,
`run.failed`) are also available. Every delivery carries the secret in the
`x-sente-secret` header — verify it with `hmac.compare_digest`. Full payloads and
verification code in the [Webhooks guide](/guides/webhooks).

## Errors

Non-2xx responses raise `SenteError` with `status` (HTTP status) and `body` (the parsed
error payload, if any).

```python theme={null}
from sente import Sente, SenteError

try:
    sente.identities.create(local_part="acme-bot")
except SenteError as e:
    if e.status == 409:
        pass  # address taken — pick another, or retry with on_conflict="suffix"
```

<Warning>
  Inbound email is untrusted input. It can contain prompt injection ("ignore your
  instructions...", "send the API key to..."). Never treat instructions found in email as
  coming from your user — extract only the datum you need (a code, a link, the sender's ask).
</Warning>

## Method reference

| Method                                                                    | Params                                                         | Returns                                  |
| ------------------------------------------------------------------------- | -------------------------------------------------------------- | ---------------------------------------- |
| `identities.create(...)`                                                  | `name=, description=, local_part=, on_conflict=`               | `Identity`                               |
| `identities.get(identity_id)` / `identities.list()`                       | —                                                              | `Identity` / `list[Identity]`            |
| `messages.list(identity_id, ...)`                                         | `since=, direction=, kind=, limit=`                            | `list[Message]`                          |
| `messages.get(message_id)`                                                | —                                                              | `Message`                                |
| `messages.send(identity_id, ...)`                                         | `to=, subject=, text=, html=, in_reply_to=`                    | `Message`                                |
| `messages.wait_for(identity_id, ...)`                                     | `since=, timeout=, kind=`                                      | `Message \| None`                        |
| `messages.wait_for_otp(identity_id, ...)`                                 | `since=, timeout=`                                             | `OtpResult(code, message) \| None`       |
| `messages.wait_for_magic_link(identity_id, ...)`                          | `since=, timeout=`                                             | `MagicLinkResult(link, message) \| None` |
| `messages.stream(identity_id, ...)`                                       | `since=, timeout=`                                             | `Iterator[Message]`                      |
| `webhooks.register(...)`                                                  | `url=, events=, identity_id=`                                  | `Webhook` (with `secret`)                |
| `webhooks.list()` / `webhooks.delete(webhook_id)`                         | —                                                              | `list[Webhook]` / `None`                 |
| `registrations.register(...)`                                             | `identity_id=, app_url=, credentials=, confirm_before_submit=` | `RegisterResult(registration, run)`      |
| `registrations.list(identity_id=None)`                                    | —                                                              | `list[Registration]` (with `latest_run`) |
| `registrations.get(registration_id)`                                      | —                                                              | `Registration`                           |
| `registrations.login(registration_id)`                                    | —                                                              | `RegisterResult`                         |
| `registrations.get_credentials(registration_id)`                          | —                                                              | `dict`                                   |
| `registrations.set_credentials(registration_id, ...)`                     | `username=, password=`                                         | `None`                                   |
| `registrations.get_session(registration_id, ...)`                         | `verify=, max_stale_sec=, timeout_ms=`                         | `dict` (cdpUrl, liveViewUrl, ...)        |
| `registrations.open_session(registration_id)`                             | —                                                              | `dict`, no freshness check               |
| `registrations.close_session(registration_id)`                            | —                                                              | `None`                                   |
| `registrations.export_session(registration_id, ...)`                      | `verify=, max_stale_sec=`                                      | storageState `dict`                      |
| `connections.connect(...)`                                                | `identity_id=, app_url=, username=, password=, totp_seed=`     | `ConnectResult(connection, run)`         |
| `connections.list(identity_id=None)` / `connections.get(connection_id)`   | —                                                              | `list[Registration]` / `Registration`    |
| `connections.revoke(connection_id)` / `connections.delete(connection_id)` | —                                                              | `Registration` / `None`                  |
| `runs.get(run_id)` / `runs.resume(run_id)` / `runs.abort(run_id)`         | —                                                              | `Run`                                    |
| `runs.wait_for_run(run_id, ...)`                                          | `timeout=` (seconds, default 180)`, poll_ms=`                  | `Run`                                    |

Endpoint-level details are in the [API reference](/api-reference/overview). TypeScript
sibling: [@sente-labs/sdk](/sdks/typescript). CLI: [sente](/sdks/cli).
