Skip to content

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

Terminal window
npm install @extrovert.dev/sdk@next
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.

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 });

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.

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

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();
MethodMaps 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 …/messagesPage<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 …/threadsPage<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.

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 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().

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

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

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

// 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)

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);

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.

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 lists the rule for each.

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

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.

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.