# TypeScript SDK

{/* // TS SDK */}

`@extrovert.dev/sdk` is the official TypeScript client for the agent-plane `/v1` API. It adds typed
resources, retries, inbox handles, an offline mock backend, and webhook-signature helpers.
**Explicit prerelease tag:** The SDK is published under npm's `next` tag, not `latest`. The packaged
  [MCP server](https://docs.extrovert.dev/mcp/overview/) is the framework-agnostic adapter for agent hosts, while this client
  is for JavaScript and TypeScript code that talks to the API directly.

## Install

```bash frame="terminal"
npm install @extrovert.dev/sdk@next
```
**Pin dogfood builds:** Use `@extrovert.dev/sdk@0.1.0-pre.6` when a test needs a reproducible contract snapshot. Public source
  is mirrored at `extrovert-dot-dev/extrovert-js`.

## Authenticate

```ts title="client.ts"
import { Extrovert } from "@extrovert.dev/sdk";

const extrovert = new Extrovert({
  apiKey: process.env.EXTROVERT_API_KEY!,        // pk_agent_… (or pk_enroll_… for enroll)
  baseUrl: process.env.EXTROVERT_API_BASE_URL,   // defaults to https://api.extrovert.dev
});
```

Set `baseUrl: "mock"` or `EXTROVERT_API_BASE_URL=mock` to run the SDK against deterministic built-in
fixtures without a network connection or API key.

## Bootstrapping a key
**Free signup is paused:** The live API currently returns `403 signup_disabled` for signup and pending verification. Use an
  enrollment token for new integrations. `GET /v1/signup-status` reports the current switch.

```ts title="sign-up.ts"
const response = await fetch("https://api.extrovert.dev/v1/agent/sign-up", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ human_email: "you@example.com", username: "support" }),
});
const signup = await response.json();
// signup.agent_key is a limited-permission key shown once.

const limited = new Extrovert({ apiKey: signup.agent_key });
const verified = await limited.verify({ otp: "492013" });     // unlock full permissions
const extrovert = new Extrovert({ apiKey: verified.agent_key });
```

  ```ts title="enroll.ts"
const bootstrap = new Extrovert({ apiKey: process.env.EXTROVERT_ENROLLMENT_KEY! });

// Returns a new client authenticated with the issued agent key.
const { client, enrollment } = await bootstrap.enrolled({
  token: process.env.EXTROVERT_ENROLLMENT_KEY!, // the raw pk_enroll_... token (required)
  agent_handle: "support-bot",                  // idempotent: same handle → same agent
  agent_name: "Support Bot",                    // optional human-readable label
});

const inbox = await client.inboxes.create();
```

  `extrovert.whoami()` returns the current key's customer, fixed `org_id` and `project_id`, agent, key id,
and `scopes` wire field. A project-bound key always acts within its bound project.

## The inbox handle

`inboxes.create()` and `inboxes.get()` return an **`InboxHandle`** bound to one address, so your agent
code reads naturally:

```ts title="inbox-handle.ts"
const inbox = await extrovert.inboxes.create({ display_name: "Support Bot" });

// `intent` is the human reviewer's one-sentence context. Accounts start on the
// require_review policy, so a send without it is rejected 422 intent_required.
await inbox.send({
  to: "ops@acme.test",
  subject: "hi",
  text: "Reporting in.",
  intent: { summary: "Introduce the support bot to ops" },
});
const { items } = await inbox.messages({ unread: true });
const result = await inbox.waitForEmail({ from: "ops@acme.test", timeout_seconds: 120 });
await inbox.reply({
  message_id: result.message!.id,
  text: "On it.",
  intent: { summary: "Acknowledge ops so the thread is not left hanging" },
});
await inbox.delete();
```

### Methods on `InboxHandle`

| Method | Maps to |
|---|---|
| `inbox.send(req)` | `POST /v1/inboxes/{inbox_id}/send` (review-policy governed; pass `intent`) |
| `inbox.reply(req)` | `POST /v1/inboxes/{inbox_id}/reply` (thread-aware; set `reply_all: true` for reply-all; review-policy governed) |
| `inbox.forward(messageId, req)` | `POST …/messages/{id}/forward` (review-policy governed) |
| `inbox.submitForReview(req)` | the same submit, spelled explicitly |
| `inbox.messages(params)` | `GET …/messages` → `Page<Message>` |
| `inbox.search({ q })` | `GET …/messages/search` |
| `inbox.messageRaw(messageId)` | `GET …/messages/{id}/raw` (the `.eml` string) |
| `inbox.markRead(messageId, { read })` | `PATCH …/messages/{id}` |
| `inbox.deleteMessage(messageId, expunge?)` | Move one message to Trash or permanently remove it |
| `inbox.batchUpdateMessages(req)` | Mark messages read/unread and optionally move them to a folder |
| `inbox.attachments(messageId)` | `GET …/messages/{id}/attachments` |
| `inbox.attachment(messageId, attId)` | download bytes (`AttachmentDownload`) |
| `inbox.threads(params)` | `GET …/threads` → `Page<Thread>` |
| `inbox.searchThreads({ q })` | `GET …/threads/search` |
| `inbox.thread(threadId)` | `GET …/threads/{id}` → `ThreadDetail` |
| `inbox.deleteThread(threadId, expunge?)` | Move a thread to Trash or permanently remove it |
| `inbox.waitForEmail(req)` | `POST …/wait` (long-poll; read timeout managed for you) |
| `inbox.registerWebhook(req)` | `POST /v1/webhooks` scoped to this inbox |
| `inbox.update(req)` | `PATCH /v1/inboxes/{inbox_id}`; display name, webhook, or `metadata` (shallow merge) |
| `inbox.refresh()` / `inbox.delete()` | get / delete the inbox |

Every returned `Message` preserves the real MIME alternatives: `text` is decoded `text/plain` or `null`,
and `html` is decoded `text/html` or `null`. Extrovert never generates one from the other.
`extracted_text` / `extracted_html` are nullable, best-effort quote/signature-stripped derivatives; keep
the source fields as ground truth. Source `html` is intentionally unsanitized API data, so sanitize it
before inserting it into a browser DOM.

Attach arbitrary state with `metadata` at create or update time. Values are string, number, or boolean,
the patch is a shallow merge (a key set to `null` deletes it, top-level `metadata: null` clears all),
and `inbox.metadata` reads the last-known object. See [Inbox metadata](https://docs.extrovert.dev/api/inboxes/#inbox-metadata).

## Projects, inbox ids & key tiers

An inbox's canonical key is its **opaque `id`** (`pmbx_…`); the SDK accepts the id (or the email
address as a within-project alias) wherever a handle is taken. The canonical addressing chain puts the
project in the path:

```ts
// canonical project-prefixed chain (mirrors /v1/projects/{project_id}/inboxes/{inbox_id})
// projectId is the required first positional; read it from whoami() (or "-" for the org wildcard).
const projectId = (await extrovert.whoami()).project_id ?? "-";
const inbox = await extrovert.projects.inboxes.create(projectId, { display_name: "Support Bot" });
await extrovert.projects.inboxes.get(projectId, inbox.id);

// bare sugar resolves to the key's bound project
await extrovert.inboxes.create({ display_name: "Support Bot" });
```

The permission ceiling lives in the **key** (`org`, `project`, or `inbox`) and narrows by **path**,
never a header or `project_id` body field. A **project**-tier key (the default from signup/enroll) lists
its own project; an **org**-tier key narrows to any project in its subtree, or lists the whole subtree
via the wildcard. The bare list on an org key is `breadth_required` until you pick. Read your authority
with `extrovert.whoami()`.

## Top-level resources

For cross-inbox work the client also exposes flat resources. List methods return the one
`{ object: "list", data, has_more, next_cursor }` envelope; pass the opaque `next_cursor` back as
`cursor`:

```ts
const projectId = (await extrovert.whoami()).project_id ?? "-";
await extrovert.projects.inboxes.list(projectId);     // List<Inbox> for the key's project
await extrovert.projects.inboxes.list("-");           // org-wide (org keys only)
await extrovert.messages.get("msg_8Tz");              // resolve inbox from id
await extrovert.messages.markRead("pmbx_8f3c2a1b", "msg_8Tz", { read: true });
await extrovert.threads.get("pmbx_8f3c2a1b", "thread_9aB");
await extrovert.webhooks.register({ url: "https://…/inbound", events: ["message.received"] });
await extrovert.webhooks.list();                      // secrets redacted
```

### Commerce requests

Agents with `commerce:request` can quote a domain and create, inspect, cancel, or poll purchase and
plan-change requests. These methods never approve a request or spend directly.

```ts
const quote = await extrovert.commerce.quoteDomain({ domain: "example.com" });

const request = await extrovert.commerce.requestDomainPurchase({
  domain: quote.domain,
  scope: "project",
  rationale: "Create an inbox for customer support",
  idempotency_key: "support-domain-2026-09-01",
});

const current = await extrovert.commerce.get(request.id);
await extrovert.commerce.cancel(request.id); // only while the current state permits it
```

Use `blockers`, `agent_next_action`, `approval_url`, `retry_safe`, and `poll_after_seconds` from the
response instead of inferring state from HTTP status alone. See
[Purchase approvals for agents](https://docs.extrovert.dev/concepts/purchase-approvals-for-agents/).

### Review Loop (HITL)

Every `send`, `reply`, and `forward` goes through the Review Loop. The account's
[review policy](https://docs.extrovert.dev/api/inboxes/#effective_review_policy-know-before-you-send) decides
whether the message is queued for a human or released, and there is no agent-plane
way to opt out. Accounts start at `require_review`, so the default outcome is a queued
review, and a call with no `intent` throws `422 intent_required`. `submitForReview` is
the explicit spelling of the same submit; `mode: "review"` asserts the queue even under
a permissive policy.

`extrovert.reviews` monitors the queue and the durable nudge stream;
`extrovert.categories` is the shared **category registry** an agent matches against
before composing. Matching is lexical on the service side. Human
actions (approve / edit / reject, category merge / delete) are console-only.

The stable JSON shapes here are the published, versioned
[agent contract](https://docs.extrovert.dev/review-loop/agent-contract/): the SDK exports `CONTRACT_VERSION`
(provisional `0.1.0-pre.6`) and a `CONTRACT_MANIFEST` naming every shape, and a conformance test
fails the build if the published types ever drift from the wire. Pin `CONTRACT_VERSION`.

```ts
await extrovert.reviews.list({ state: "needs_review" });   // Page<Review>
await extrovert.reviews.get("rr_8Tz");                     // current draft + intent + state
await extrovert.reviews.turns("rr_8Tz");                   // append-only thread

// Match the registry before composing; propose only if nothing fits.
const cats = await extrovert.categories.list({ match: "sales outreach" });
const cat = cats.items[0] ?? (await extrovert.categories.propose({
  name: "Sales Outreach",
  description: "cold outbound to prospects about the pilot",
}));
await extrovert.categories.update(cat.id, { description: "…refined matcher text" });

// Read the ordered rule set before composing. The server applies precedence.
const rules = await extrovert.rules.get({ category_id: cat.id }); // Page<Rule>, highest precedence first
// rules.items includes the house-style (general) layer + this category's rules.

// Then submit, keyed on the opaque cat_ id (never the name).
await inbox.submitForReview({
  to: "lead@acme.test",
  subject: "Q3 pilot",
  text: "Can we chat Thursday?",
  mode: "review",
  intent: { summary: "re-engage cold lead", meta: { goal: "book_meeting" } },
  category_id: cat.id,
  composition_token: rules.composition_token,
});
```

An unfiltered `rules.get({ category_id })` response includes a short-lived `composition_token` proving
which effective rule snapshot the agent used. Pass it on send, reply, forward, or review revision when
the active policy requires fresh composition. Filtered rule reads do not return this token.

`extrovert.rules` is the shared **writing-rule** store + house-style + audit/undo. ANY
agent in the project may write/edit/promote/retire/undo rules; the change audit log is the
safety net. Rules are append-only by supersession; an edit is a new revision, never an
in-place mutation; and `undo` restores the prior version as a new forward supersession.

Rules carry a `rule_layer`; `org` (house-style inherited by every project in the org) or `project`
(layered on top). `GET /v1/rules` returns the **effective** stack for the key's `{org_id, project_id}`
with the precedence ladder applied server-side: per-agent project rule → project category rule →
project general rule → org house-style rule. Agent saves are **always** project-layer; org-wide
house-style rules are authored from the console, not the agent plane. The `scope` you set on
`save`/`promote` (`general` vs `category`) is a separate axis from the layer; a `general` rule saved
by an agent is project-general, not org-wide.

```ts
// Turn a human's edit/comment into a rule (the judgment is the agent's LLM call, $0 to us).
const rule = await extrovert.rules.save({
  category_id: cat.id,          // omit for a house-style (general) rule, applied across all categories
  rule_text: "be more pushy, we need MRR",
  source_review_id: "rr_8Tz",
});
await extrovert.rules.promote(rule.id, "general");  // make it house-style
await extrovert.rules.retire(rule.id);              // soft delete (history survives)

// Audit + undo (both planes).
const audit = await extrovert.rules.audit({ entity_kind: "rule" });
await extrovert.rules.undo(audit.items[0]!.id);     // restore the prior version (idempotent: re-undo → 409)
```

## Pagination

Cursor-paginated collections, including inbox lists, return one `List<T>` envelope:
`{ object: "list", data, has_more, next_cursor }`; with an **opaque** `next_cursor`. The older
message/thread reads return `{ items, total, next_cursor? }`. Either way, pass the opaque cursor back as
`cursor` to fetch the next page:

```ts
// cursor-paginated List<T>: `list()` returns a ListPage that auto-paginates.
const projectId = (await extrovert.whoami()).project_id ?? "-";
const page = await extrovert.projects.inboxes.list(projectId, { limit: 50 });
for await (const inbox of page) handle(inbox);   // walks every page lazily

// …or drive the opaque cursor by hand (page.nextCursor is null when done):
let cursor = page.nextCursor;
while (cursor) {
  const next = await extrovert.projects.inboxes.list(projectId, { limit: 50, cursor });
  for (const inbox of next.data) handle(inbox);
  cursor = next.nextCursor;        // opaque; null when done
}

// message/thread Page<T>
let mcursor: string | undefined;
do {
  const page = await inbox.messages({ limit: 50, cursor: mcursor });
  for (const m of page.items) handle(m);
  mcursor = page.next_cursor;
} while (mcursor);
```

## Retries & errors

The SDK retries idempotent requests (`GET` / `DELETE`) on `429` / `5xx` / network errors with jittered
backoff that honors `Retry-After`. Send an `Idempotency-Key` on a `POST` to make a create exactly-once.
API errors expose the RFC-9457 problem fields `code`, `status`, `detail`, and `requestId`. See
[Errors](https://docs.extrovert.dev/api/errors/).
honors Retry-After

**Branch on `err.code`, not the class.** The whole `409` family arrives as `ConflictError` and the
whole `422` family as `ValidationError`, and the code is what separates a retryable stale
compare-and-set (`stale`, `born_stale`) from one you must never retry (`terminal`, `wrong_state`,
`send_needs_reconciliation`). The [409 taxonomy](https://docs.extrovert.dev/api/errors/#the-409-taxonomy)
lists the rule for each.

## Verifying inbound webhooks

The SDK ships the signature helpers so your handler needs no crypto dependency:

```ts title="inbound-handler.ts"
import { verifyWebhookSignature, parseWebhook } from "@extrovert.dev/sdk";

export async function POST(req: Request) {
  const payload = await req.text();             // raw body; do not re-serialize
  const event = await parseWebhook({
    payload,
    signature: req.headers.get("x-extrovert-signature")!,
    secret: process.env.EXTROVERT_WEBHOOK_SECRET!,
  });
  if (!event) return new Response("bad signature", { status: 400 });
  // event.message is the message.received payload
  return new Response("ok");
}
```

`signWebhook` is exported too, for tests. The signature format is documented in
[Webhooks & HMAC](https://docs.extrovert.dev/concepts/webhooks/).

## Extraction without a wait

`extractOtp`, `extractLink`, and `extractCredentials` are exported standalone. Run them on any string
(e.g. a message body you already have) to pull a one-time code or verification link.

## Next

- [Zero to first email](https://docs.extrovert.dev/quickstart/zero-to-first-email/): the SDK end to end
- [API reference](https://docs.extrovert.dev/api/overview/): the HTTP contract
- [MCP overview](https://docs.extrovert.dev/mcp/overview/): the agent-host alternative