TypeScript 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.
Install
Section titled “Install”npm install @extrovert.dev/sdk@nextAuthenticate
Section titled “Authenticate”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
Section titled “Bootstrapping a key”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 permissionsconst extrovert = new Extrovert({ apiKey: verified.agent_key });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
Section titled “The inbox handle”inboxes.create() and inboxes.get() return an InboxHandle bound to one address, so your agent
code reads naturally:
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
Section titled “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.
Projects, inbox ids & key tiers
Section titled “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:
// 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 projectawait 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
Section titled “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:
const projectId = (await extrovert.whoami()).project_id ?? "-";await extrovert.projects.inboxes.list(projectId); // List<Inbox> for the key's projectawait extrovert.projects.inboxes.list("-"); // org-wide (org keys only)await extrovert.messages.get("msg_8Tz"); // resolve inbox from idawait 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 redactedCommerce requests
Section titled “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.
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 itUse 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.
Review Loop (HITL)
Section titled “Review Loop (HITL)”Every send, reply, and forward goes through the Review Loop. The account’s
review policy 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: 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.
await extrovert.reviews.list({ state: "needs_review" }); // Page<Review>await extrovert.reviews.get("rr_8Tz"); // current draft + intent + stateawait 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.
// 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-styleawait 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
Section titled “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:
// 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
Section titled “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.
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
lists the rule for each.
Verifying inbound webhooks
Section titled “Verifying inbound webhooks”The SDK ships the signature helpers so your handler needs no crypto dependency:
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.
Extraction without a wait
Section titled “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.
- Zero to first email: the SDK end to end
- API reference: the HTTP contract
- MCP overview: the agent-host alternative