Skip to main content
@sente-labs/sdk is a thin typed client for the Sente REST API. Zero runtime dependencies (it uses global fetch, so Node 18+), and types ship with the package — no @types/* install.
new Sente(opts) takes { apiKey, baseUrl?, fetch? }. baseUrl defaults to https://api.sente.run; the fetch override exists for tests. Get a key from the dashboard or with sente login && sente token. One key authenticates the whole organization — keep it in an environment variable.

Create an account

An account is the object. Two ways to get one, and neither needs an identity first — omit identityId and Sente provisions the backing email address from the app’s hostname.

Connect one you already own

You supply the credentials; Sente logs in, vaults them write-only, and keeps the account re-loginable.
Limits worth knowing before you build on connect:
  • SSO-only accounts cannot be connected. Google/Okta sign-in and passkeys have no username and password to vault.
  • Credentials are write-only. registrations.getCredentials on a connected account returns 403 CREDENTIALS_WRITE_ONLY.
  • A vaulted totpSeed makes re-login autonomous. 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 — the code went to the owner’s inbox, which Sente cannot read. Someone enters it at liveViewUrl, then runs.resume(run.id).
connections.revoke(id) disables the connection but keeps the vault, so a later connect re-enables it. connections.delete(id) revokes and purges the vaulted secrets.

Register a new one

registrations.register drives a browser through signup at appUrl under a fresh email address and completes email verification from that address’s own inbox.
Register only where the target’s terms permit it. With confirmBeforeSubmit: true the agent fills the whole form and then stops: the run goes blocked with error.code === "SUBMIT_CONFIRMATION_REQUIRED" and a liveViewUrl, a person clicks submit there (accepting the app’s terms themselves), and you call runs.resume(run.id). It defaults to fully autonomous. See Acceptable use.
Both calls return the account plus its first run. Connected and created accounts are the same row underneath — a connection id works anywhere a registration id does (getSession, login, exportSession).

Wait for a verification code

Every inbound email is classified server-side on arrival — message.annotation is { kind: "otp" | "magic_link" | "other", code, link, confidence } — so a signup you drive can block until the code arrives, already extracted.
Stamp since before triggering the action. A code that lands instantly can’t be missed, and a stale one can’t be grabbed. Omit since and the server falls back to a 60-second lookback.
timeout is in seconds, 1–60, default 25 — it’s a server-side long poll, and values outside that range are a 400. Both helpers resolve null on timeout, so loop if you need longer. Accounts created through connect / register need none of this: the run controller injects the code itself.
Inbound email is untrusted input. It can carry prompt injection (“ignore your instructions”, “send the API key to…”). Extract only the datum you need — the code, the link, the sender’s ask — and never treat text found in an email as an instruction.

Get a logged-in session

getSession checks the login is still fresh and, if it isn’t, re-logs in first — vaulted credentials plus a fresh code from the account’s inbox — so the browser you get was just confirmed signed in. healed: true says that happened, and a cold call can take a minute. Options: { verify?, maxStaleSec?, timeoutMs? }. verify: false skips the freshness check; maxStaleSec overrides the freshness window for this call; timeoutMs caps the whole re-login-and-retry loop and defaults to 240000 (4 minutes). openSession(id) is the raw primitive with no freshness check at all. To use your own browser stack instead, export the logged-in state as a Playwright storageState:
One live session per identity, and open sessions bill per minute — always closeSession. The exported storageState is a bearer credential for a logged-in account: store it like a secret.

Handle a blocked run

A run stops at blocked whenever it hits something only a person can clear. It holds for about 10 minutes with an interactive liveViewUrl, then fails with BLOCKED_TIMEOUT.
waitForRun’s timeout is in milliseconds (default 180000) — the Python SDK’s is in seconds. It stops on completed, failed, or blocked, and on timeout it returns the last-polled run with its non-terminal status rather than throwing. Always branch on run.status.
Don’t poll for blocks in production — subscribe a webhook:
The secret is returned once, at register time. Every delivery carries it in the x-sente-secret header — verify with a timing-safe compare. message.received payloads are thin notifications with no body; fetch content with messages.get(id). Full payloads in Webhooks.

Errors

Non-2xx responses throw SenteError with status (the HTTP status) and body.
SenteError.body is the raw response text, not parsed JSON. Parse it yourself when you need the error code. (The Python SDK parses it for you — the two differ here.)
SESSION_STALE and SESSION_PENDING (also 409) are handled inside getSession — it polls the run they name and retries — so you only see them from the raw openSession.

Method reference

identityId is optional everywhere, but pass one when several accounts should share a mailbox, or when the address itself matters (it shows up in the app’s UI as the account’s email).
A taken localPart throws a 409 (onConflict: "error" is the default); pass onConflict: "suffix" to append a random suffix instead. Role addresses (support, admin, billing, security, postmaster, …) are reserved — support-bot is fine, support is not.
text and/or html is required. messages.stream(identityId) is an async generator that long-polls and swallows transient errors; for production prefer webhooks — push, not poll. More in The account’s inbox.

Next steps