@extrovert.dev/sdk 0.1.0-pre.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Message Science
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,591 @@
1
+ <!-- // SCOPED EMAIL FOR AGENTS -->
2
+
3
+ # @extrovert.dev/sdk
4
+
5
+ **A real inbox for your agent, in one call.**
6
+
7
+ The TypeScript SDK for [Extrovert](https://extrovert.dev): Message Science's agent-email
8
+ platform. Extrovert gives an AI agent a real, persistent inbox on a domain we own: created in one
9
+ call, sends and receives, behind a scoped key that expires and revokes on its own.
10
+
11
+ - **One call to a live inbox.** Paid accounts use `agent7@extrovertmail.com`; free
12
+ signups use `agent7@free.extrovertmail.com`. No DNS setup is required.
13
+ - **`waitForEmail`, the killer primitive.** Block until the next matching message lands and get the
14
+ OTP code / verification link extracted as a structured field. No polling loop.
15
+ - **Typed everything.** Request and response models matching the Extrovert `/v1` contract, a typed
16
+ `ApiError` hierarchy, full `.d.ts` declarations.
17
+ - **Runs where your agent runs.** Pure `fetch`. Node 18+, Cloudflare Workers, Vercel Edge, Deno,
18
+ the browser. Zero runtime dependencies.
19
+ - **Don't hand an MCP host your master key.** Redeem a scoped enrollment key; issue an agent key that
20
+ expires and revokes on its own.
21
+
22
+ ---
23
+
24
+ ## Install the prerelease
25
+
26
+ The SDK is published on npm under the `next` dist-tag so an unqualified install cannot be mistaken
27
+ for a stable release:
28
+
29
+ ```bash
30
+ npm install @extrovert.dev/sdk@next
31
+ ```
32
+
33
+ Pin `@extrovert.dev/sdk@0.1.0-pre.10` when a dogfood test needs a reproducible contract snapshot.
34
+ Requires Node 18+ for global `fetch` and Web Crypto.
35
+
36
+ ## Build and use from source
37
+
38
+ ```bash
39
+ cd extrovert/sdk/ts
40
+ pnpm install --frozen-lockfile
41
+ pnpm typecheck
42
+ pnpm build
43
+ ```
44
+
45
+ The build produces ESM, CommonJS, and types in `dist/`. The public release source is mirrored at
46
+ [`extrovert-dot-dev/extrovert-js`](https://github.com/extrovert-dot-dev/extrovert-js), while the
47
+ REST API and served OpenAPI document remain the underlying contract.
48
+
49
+ ---
50
+
51
+ ## Quickstart: inbox in one call
52
+
53
+ Use the scoped key issued for this agent on your existing account. Do not sign up for another
54
+ account to connect a new agent. Confirm the connection with `extrovert.whoami()` before creating
55
+ resources; its capability summary explains what the connection can do.
56
+
57
+ ```ts
58
+ import { Extrovert, isQueuedForReview } from "@extrovert.dev/sdk";
59
+
60
+ const extrovert = new Extrovert({ apiKey: process.env.EXTROVERT_API_KEY! });
61
+
62
+ // One call. A real, send-and-receive-capable inbox.
63
+ const inbox = await extrovert.inboxes.create({ display_name: "Support Bot" });
64
+ console.log(inbox.address); // agent7@extrovertmail.com
65
+
66
+ // Queue a message for a human. `intent.summary` is what the reviewer reads
67
+ // first, and it is REQUIRED: under the default `require_review` policy a send
68
+ // without one is refused 422 `intent_required` (nothing sent, nothing queued).
69
+ const outcome = await inbox.send({
70
+ to: "ops@acme.test",
71
+ subject: "agent online",
72
+ text: "Reporting in.",
73
+ intent: { summary: "Tell ops the support agent is live and invite a reply." },
74
+ });
75
+
76
+ if (isQueuedForReview(outcome)) {
77
+ // The normal outcome. Nothing is delivered until a human approves.
78
+ console.log(outcome.review.id); // rr_...
79
+ }
80
+ ```
81
+
82
+ **Your agent queues mail; it does not send it.** A message parks as a review request, a human
83
+ approves / edits / rejects it, and your agent watches for that outcome and redrafts as needed.
84
+ `allow_direct` inboxes and graduated categories are the exceptions, not the default: read
85
+ `inbox.record?.effective_review_policy` once rather than learning the policy by being refused. The
86
+ other half of the loop is the [agent contract](https://docs.extrovert.dev/review-loop/agent-contract/).
87
+
88
+ `inboxes.create()` returns an `InboxHandle`: an ergonomic handle bound to one address, so the rest
89
+ of your agent code reads naturally: `inbox.send(...)`, `inbox.messages()`, `inbox.waitForEmail(...)`,
90
+ `inbox.delete()`. This is the curl-style sugar that resolves to your key's default project.
91
+
92
+ ---
93
+
94
+ ## Check whether your domain is ready
95
+
96
+ ```ts
97
+ const domain = await extrovert.domains.get("mail.example.com");
98
+ const readiness = domain.readiness;
99
+ if (!readiness) throw new Error("This server did not return domain readiness.");
100
+ console.log(readiness.label, readiness.summary);
101
+ console.log(readiness.inboxes); // counts visible to this connection, not a global total
102
+
103
+ if (readiness.ready_for_inboxes && readiness.next_action === "create_inbox") {
104
+ console.log("Ready to create an inbox when you want one.");
105
+ } else if (readiness.action_required_by === "extrovert") {
106
+ console.log("Extrovert is handling the next steps. No DNS changes are needed.");
107
+ }
108
+
109
+ // A deliberate recheck verifies the DNS entries now; ordinary reads do not.
110
+ // Use after publishing/fixing the entries, not in a tight polling loop.
111
+ await extrovert.domains.verify("mail.example.com");
112
+
113
+ // Wait at most 45 seconds. A timeout does not stop background setup.
114
+ const waiting = await extrovert.domains.wait("mail.example.com", { timeout_seconds: 45 });
115
+ if (waiting.outcome === "timed_out") {
116
+ console.log(`Resume checking in ${waiting.resume_after_seconds} seconds.`);
117
+ }
118
+
119
+ // Persist next_cursor privately and pass it as after on the next check.
120
+ const updates = await extrovert.domains.events("mail.example.com", { after: "0", limit: 50 });
121
+ for (const event of updates.items) console.log(event.summary);
122
+ ```
123
+
124
+ `waiting_for_dns` means your DNS entries are not confirmed yet; `setting_up` means Extrovert is
125
+ finishing setup. `ready` means mail setup is complete. `action_required` identifies a customer DNS
126
+ repair; `needs_attention` identifies work for Extrovert. Temporary `checking` results are inconclusive,
127
+ not a request to change DNS. Use the returned summary and next action when answering a person.
128
+
129
+ Do not infer readiness from legacy verification or signing fields. Ready inboxes still follow their
130
+ permissions, account limits, and review rules. Setup continues while your agent is disconnected, but
131
+ the agent must stay connected or resume its event/status checks to receive updates.
132
+
133
+ ## The canonical chain: `projects.inboxes.*`
134
+
135
+ Scope lives in your **key**, not in headers. A broad (org-tier) key narrows to one **project** by
136
+ path; a project/inbox key is already pinned. The canonical, contract-aligned surface is the
137
+ `projects.inboxes.*` chain, keyed by the **opaque `inbox_id`** (the inbox address is accepted as a
138
+ within-project alias):
139
+
140
+ ```ts
141
+ const x = new Extrovert({ apiKey: process.env.EXTROVERT_API_KEY! });
142
+ const { project_id } = await x.whoami(); // the key's fixed project
143
+
144
+ // Create / send / list in a project: keyed by the opaque inbox_id.
145
+ const inbox = await x.projects.inboxes.create(project_id!, { username: "ada" });
146
+ await x.projects.inboxes.send(project_id!, inbox.id, { to: "ops@acme.test", subject: "hi", text: "…",
147
+ intent: { summary: "…one sentence for the human reviewer…" } }); // queues for review
148
+
149
+ // One list envelope: { object: "list", data, has_more, next_cursor }. The ListPage
150
+ // auto-paginates over OPAQUE cursors: never thread a cursor by hand.
151
+ const page = await x.projects.inboxes.list(project_id!, { limit: 50 });
152
+ for await (const ib of page) console.log(ib.id); // walks every page
153
+ const all = await (await x.projects.inboxes.list(project_id!)).collect(); // eager
154
+
155
+ // Expand relations (per-resource allowlist, depth ≤ 2):
156
+ await x.projects.inboxes.get(project_id!, inbox.id, { include: ["agent", "domain"] });
157
+ ```
158
+
159
+ An **org-tier** key can fan out across its subtree with the `-` wildcard
160
+ (`x.projects.inboxes.list("-")`); a non-org key on the wildcard is a `forbidden_scope` 403, and an
161
+ org key on a bare list is a `breadth_required` 400 (see [Errors](#errors)). The advisory
162
+ `x.keyTier` (`org` | `project` | `inbox`) is derived from your key prefix so you can branch before a
163
+ round-trip.
164
+
165
+ The SDK pins a dated **`Extrovert-Version`** header on every request (default `x.apiVersion`, the
166
+ latest this SDK was built against); pin an older dated version with `new Extrovert({ apiVersion })`
167
+ to opt into the server's transform shim.
168
+
169
+ ---
170
+
171
+ ## The OTP flow: `waitForEmail`
172
+
173
+ Agents sign up for things. The high-value, time-boxed task is "wait for the verification email and
174
+ read the code." Extrovert holds the request open, polls the mailbox server-side, and hands you the extracted code.
175
+
176
+ ```ts
177
+ import { Extrovert } from "@extrovert.dev/sdk";
178
+
179
+ const extrovert = new Extrovert({ apiKey: process.env.EXTROVERT_API_KEY! });
180
+ const inbox = await extrovert.inboxes.create({ username: "signup-agent" });
181
+
182
+ // ... trigger a sign-up that emails an OTP to inbox.address ...
183
+ await fetch("https://acme.test/signup", {
184
+ method: "POST",
185
+ body: JSON.stringify({ email: inbox.address }),
186
+ });
187
+
188
+ // Block until it lands (up to 2 min), then read the structured result.
189
+ const result = await inbox.waitForEmail({
190
+ from: "no-reply@acme.test",
191
+ subject: "verification",
192
+ timeout_seconds: 120,
193
+ });
194
+
195
+ if (result.timed_out) throw new Error("no email in time");
196
+
197
+ console.log(result.extracted.otp); // "492013"
198
+ console.log(result.extracted.link); // "https://acme.test/verify?token=..."
199
+
200
+ // ... submit result.extracted.otp back to the form ...
201
+ ```
202
+
203
+ `extracted.otp` and `extracted.link` come from the same extraction machinery that has pulled OTPs out
204
+ of real warmup mail for years. Need it standalone? Import `extractOtp` / `extractLink` /
205
+ `extractCredentials`.
206
+
207
+ ---
208
+
209
+ ## Try it offline (no API key, no network)
210
+
211
+ Run the whole SDK against built-in, deterministic fixtures: no key, no network. Every method
212
+ works, including a synthesized `waitForEmail` OTP, and the mock models the real review policy,
213
+ so a send without an `intent` is refused offline exactly as it would be live:
214
+
215
+ ```ts
216
+ const extrovert = new Extrovert({ transport: "mock" });
217
+ const inbox = await extrovert.inboxes.create();
218
+ const { extracted } = await inbox.waitForEmail();
219
+ console.log(extracted.otp); // a fixture OTP: no network touched
220
+ ```
221
+
222
+ Set `EXTROVERT_API_BASE_URL=mock` to flip every client into offline mode from the environment.
223
+
224
+ Offline domain setup remains `waiting_for_dns`: it does not query DNS, run background setup, or
225
+ produce lifecycle events. Recheck does not fabricate a ready result. Use a test HTTP response for
226
+ readiness transitions and resumable event scenarios; only the live service confirms actual setup.
227
+
228
+ ---
229
+
230
+ ## Scoped keys: redeem an enrollment key
231
+
232
+ The identity model: a human (or org-admin call) **issues** a scoped `pk_enroll_...` enrollment key
233
+ that can create up to *N* inboxes and nothing else. An agent **redeems** it for a short-lived,
234
+ individually-revocable `pk_agent_...` key.
235
+
236
+ ```ts
237
+ // The agent is handed only the enrollment key: never an org-wide key.
238
+ const bootstrap = new Extrovert({ apiKey: process.env.EXTROVERT_ENROLLMENT_KEY! });
239
+
240
+ const { client, enrollment } = await bootstrap.enrolled({
241
+ token: process.env.EXTROVERT_ENROLLMENT_KEY!, // the raw pk_enroll_... token (required)
242
+ agent_handle: "support-bot", // idempotent: same handle -> same agent
243
+ agent_name: "Support Bot", // optional human-readable label
244
+ });
245
+
246
+ // EnrollResult carries agent_id, agent_key (shown once), scopes, org_id, project_id.
247
+ console.log(enrollment.agent_id, enrollment.scopes);
248
+ console.log(enrollment.org_id, enrollment.project_id); // the key's fixed org + project
249
+
250
+ // `client` is already authenticated with the issued agent key.
251
+ const inbox = await client.inboxes.create();
252
+
253
+ // The issued key is bound to a fixed org + project: visible via whoami, never selectable.
254
+ const me = await client.whoami();
255
+ console.log(me.org_id, me.project_id, me.scopes);
256
+ ```
257
+
258
+ ---
259
+
260
+ ## Inbox metadata: attach your own key-value data
261
+
262
+ Every inbox carries an arbitrary `metadata` object (string / number / boolean values; ≤256 keys,
263
+ ≤256 chars per key and per string value). Set it at create time, read it on every inbox shape, and
264
+ patch it in place with shallow-merge / null-delete semantics: no delete+recreate.
265
+
266
+ ```ts
267
+ // Set metadata at create time. It is echoed back on the inbox record.
268
+ const inbox = await extrovert.inboxes.create({
269
+ username: "support",
270
+ metadata: { team: "growth", tier: 2, vip: true },
271
+ });
272
+ console.log(inbox.metadata); // { team: "growth", tier: 2, vip: true }
273
+
274
+ // PATCH semantics on update():
275
+ // - omitting `metadata` leaves it unchanged
276
+ // - an object MERGES (set/overwrite the given keys)
277
+ // - a key whose value is `null` DELETES that key
278
+ // - top-level `metadata: null` CLEARS all metadata (reads back as {})
279
+ const updated = await extrovert.inboxes.update(inbox.address, {
280
+ metadata: { tier: 3, team: null }, // bump tier, delete team
281
+ });
282
+ console.log(updated.metadata); // { tier: 3, vip: true }
283
+ ```
284
+
285
+ Metadata is **project-scoped**: a key only reads/mutates inboxes in its bound project. Where the
286
+ API accepts a `project_id`, it is an **assertion** that must match the key's bound project (a
287
+ mismatch is a 403), never a selector: the project is always derived from the key. See `whoami` for
288
+ the fixed `org_id` / `project_id` the key is bound to.
289
+
290
+ ---
291
+
292
+ ## Receiving mail: read, thread, reply
293
+
294
+ ```ts
295
+ // Find a conversation. Pass next_cursor back unchanged to continue.
296
+ const firstPage = await extrovert.threads.search(inbox.address, {
297
+ q: "deployment",
298
+ limit: 25,
299
+ });
300
+ const nextPage = firstPage.next_cursor
301
+ ? await extrovert.threads.search(inbox.address, {
302
+ q: "deployment",
303
+ limit: 25,
304
+ cursor: firstPage.next_cursor,
305
+ })
306
+ : undefined;
307
+
308
+ // Read the complete oldest-first conversation before acting.
309
+ const summary = firstPage.items[0];
310
+ if (summary) {
311
+ const thread = await extrovert.threads.get(inbox.address, summary.id);
312
+ const authoredText = thread.messages.map((message) =>
313
+ message.extracted_text ?? message.text
314
+ );
315
+
316
+ // Recipients, subject, In-Reply-To, and References are derived server-side.
317
+ await extrovert.threads.reply(inbox.address, {
318
+ thread_id: thread.id,
319
+ expected_last_message_id: thread.last_message_id,
320
+ text: "On it — thanks.",
321
+ intent: { summary: "Acknowledge the deployment request." },
322
+ idempotency_key: "deployment-ack-v1",
323
+ });
324
+ }
325
+ ```
326
+
327
+ If the thread advances first, `expected_last_message_id` returns a 409: fetch the thread again and
328
+ reconsider the draft. It is an optimistic check at submission, not an atomic lock through delivery.
329
+
330
+ For an inbox-bound style, use `inbox.threads(...)`, `inbox.searchThreads(...)`,
331
+ `inbox.thread(...)`, `inbox.reply(...)`, and `inbox.deleteThread(...)`.
332
+
333
+ ---
334
+
335
+ ## Webhooks: verified inbound, anywhere
336
+
337
+ Register an HMAC-signed, timestamped webhook, then verify deliveries with Web Crypto (works in Node
338
+ and at the edge, no dependency):
339
+
340
+ ```ts
341
+ import { Extrovert, verifyWebhookSignature } from "@extrovert.dev/sdk";
342
+
343
+ const extrovert = new Extrovert({ apiKey: process.env.EXTROVERT_API_KEY! });
344
+
345
+ const webhook = await extrovert.webhooks.register({
346
+ url: "https://my-agent.example.com/inbound",
347
+ events: ["message.received"],
348
+ });
349
+ // Store webhook.secret now: it is shown once.
350
+
351
+ // In your handler (Workers / Vercel Edge / Node):
352
+ export async function POST(req: Request) {
353
+ const payload = await req.text(); // raw body: do not re-serialize
354
+ const ok = await verifyWebhookSignature({
355
+ payload,
356
+ signature: req.headers.get("x-extrovert-signature")!,
357
+ secret: process.env.EXTROVERT_WEBHOOK_SECRET!,
358
+ });
359
+ if (!ok) return new Response("bad signature", { status: 400 });
360
+ // ... handle the verified message.received event ...
361
+ return new Response("ok");
362
+ }
363
+ ```
364
+
365
+ Or `parseWebhook({ ... })` to verify and JSON-parse in one step (returns `null` on a bad signature).
366
+
367
+ ---
368
+
369
+ ## Configuration
370
+
371
+ ```ts
372
+ new Extrovert({
373
+ apiKey: "pk_agent_...", // or env EXTROVERT_API_KEY
374
+ baseUrl: "https://api.extrovert.dev", // or env EXTROVERT_API_BASE_URL; "mock" for offline
375
+ transport: "http", // "mock" to force offline fixtures
376
+ timeoutMs: 30_000, // default request timeout (waitForEmail manages its own)
377
+ retry: { maxRetries: 2, baseDelayMs: 250, maxDelayMs: 8_000 }, // idempotent 429/5xx/network
378
+ fetch: customFetch, // inject a fetch (tests, proxies, instrumentation)
379
+ defaultHeaders: { "X-Tenant": "acme" },
380
+ });
381
+ ```
382
+
383
+ | Option | Env | Default |
384
+ | ------------ | ----------------------- | ------------------------------------ |
385
+ | `apiKey` | `EXTROVERT_API_KEY` |: (required for `http` transport) |
386
+ | `baseUrl` | `EXTROVERT_API_BASE_URL` | `https://api.extrovert.dev` |
387
+ | `transport` | (`baseUrl=mock`) | `http` |
388
+ | `timeoutMs` |: | `30000` |
389
+
390
+ Idempotency: pass `client_id` to `inboxes.create()` and `idempotency_key` to `send` / `reply` :
391
+ retries won't duplicate. Cursor pagination: list responses carry `next_cursor`; pass it back as
392
+ `cursor`.
393
+
394
+ ---
395
+
396
+ ## Errors
397
+
398
+ Every non-2xx response throws a typed error extending `ApiError`. Branch on the class or `.code`:
399
+
400
+ ```ts
401
+ import {
402
+ ApiError,
403
+ AuthenticationError, // 401: key missing / expired / revoked
404
+ PermissionError, // 403: scope denied
405
+ ForbiddenScopeError, // 403 forbidden_scope: out of the key's ceiling / non-org key on the wildcard
406
+ BreadthRequiredError, // 400 breadth_required: org key on a bare list must pick a project / "-"
407
+ NotFoundError, // 404: incl. an out-of-ceiling id (never an existence oracle)
408
+ ConflictError, // 409: incl. idempotency_conflict (same key, different body)
409
+ ValidationError, // 422: see err.body.error.details
410
+ PaymentRequiredError, // 402: x402 test-mode challenge in err.paymentRequired
411
+ RateLimitError, // 429: err.retryAfter (seconds)
412
+ ConnectionError, // network failure before a response
413
+ TimeoutError, // request timed out / aborted
414
+ } from "@extrovert.dev/sdk";
415
+
416
+ try {
417
+ await x.projects.inboxes.list("-");
418
+ } catch (err) {
419
+ if (err instanceof RateLimitError) {
420
+ await sleep((err.retryAfter ?? 1) * 1000);
421
+ } else if (err instanceof ApiError) {
422
+ // The redesigned surface returns RFC-9457 problem+json; switch on the CLOSED code union.
423
+ switch (err.problemCode) {
424
+ case "forbidden_scope": /* pick your own project */ break;
425
+ case "breadth_required": /* err.problem.errors names the next call */ break;
426
+ default: console.error(err.status, err.code, err.problem?.detail, err.requestId);
427
+ }
428
+ } else {
429
+ throw err;
430
+ }
431
+ }
432
+ ```
433
+
434
+ Every `ApiError` carries `status`, `code`, `requestId`, `body`, and `isClientError` / `isServerError`.
435
+ On the redesigned surface it additionally carries the parsed RFC-9457 `problem` and the typed
436
+ `problemCode` (the closed `ProblemCode` union: `bad_request`, `unauthorized`, `forbidden_scope`,
437
+ `not_found`, `conflict`, `idempotency_conflict`, `breadth_required`, `quota_exceeded`, `rate_limited`,
438
+ `domain_not_allowed`, `recipient_blocked`, `not_configured`, `domain_unavailable`, `internal`). The
439
+ legacy `{ error, message }` envelope is still parsed for back-compat. GET and DELETE requests retry
440
+ automatically on 429/5xx/network errors with jittered backoff that honors `Retry-After`.
441
+
442
+ ---
443
+
444
+ ## API surface
445
+
446
+ Maps 1:1 to the Extrovert `/v1` REST contract.
447
+
448
+ | SDK | Endpoint |
449
+ | ------------------------------------- | ------------------------------------- |
450
+ | `extrovert.enroll()` / `.enrolled()` | `POST /v1/enroll` |
451
+ | `x.projects.inboxes.create(p, ...)` | `POST /v1/projects/{project_id}/inboxes` |
452
+ | `x.projects.inboxes.list(p, ...)` | `GET /v1/projects/{project_id}/inboxes` (List envelope) |
453
+ | `x.projects.inboxes.get(p, inbox_id)` | `GET /v1/projects/{project_id}/inboxes/{inbox_id}` |
454
+ | `x.projects.inboxes.update(p, inbox_id)`| `PATCH /v1/projects/{project_id}/inboxes/{inbox_id}` |
455
+ | `x.projects.inboxes.delete(p, inbox_id)`| `DELETE /v1/projects/{project_id}/inboxes/{inbox_id}`|
456
+ | `x.projects.inboxes.credentials(...)` | `GET /v1/projects/{project_id}/inboxes/{inbox_id}/credentials` |
457
+ | `extrovert.inboxes.create()` *(sugar)* | `POST /v1/inboxes` |
458
+ | `extrovert.inboxes.list()` *(sugar)* | `GET /v1/inboxes` |
459
+ | `extrovert.inboxes.get(addr)` *(sugar)* | `GET /v1/inboxes/{inbox_id}` |
460
+ | `extrovert.inboxes.delete(addr)` *(sugar)* | `DELETE /v1/inboxes/{inbox_id}` |
461
+ | `inbox.send()` | `POST /v1/inboxes/{addr}/send` |
462
+ | `inbox.messages()` | `GET /v1/inboxes/{addr}/messages` |
463
+ | `inbox.threads()` | `GET /v1/inboxes/{addr}/threads` |
464
+ | `inbox.searchThreads()` | `GET /v1/inboxes/{addr}/threads/search` |
465
+ | `inbox.thread(thread_id)` | `GET /v1/inboxes/{addr}/threads/{thread_id}` |
466
+ | `inbox.reply({ thread_id, ... })` | `POST /v1/inboxes/{addr}/reply` |
467
+ | `inbox.deleteThread(thread_id)` | `DELETE /v1/inboxes/{addr}/threads/{thread_id}` |
468
+ | `inbox.waitForEmail()` | `POST /v1/inboxes/{addr}/wait` |
469
+ | `extrovert.inboxes.update(addr, ...)` | `PATCH /v1/inboxes/{addr}` |
470
+ | `extrovert.messages.get(id)` | `GET /v1/messages/{id}` |
471
+ | `extrovert.threads.list(inbox, ...)` | `GET /v1/inboxes/{inbox}/threads` |
472
+ | `extrovert.threads.search(inbox, ...)` | `GET /v1/inboxes/{inbox}/threads/search` |
473
+ | `extrovert.threads.get(inbox, id)` | `GET /v1/inboxes/{inbox}/threads/{id}` |
474
+ | `extrovert.threads.reply(inbox, ...)` | `POST /v1/inboxes/{inbox}/reply` |
475
+ | `extrovert.threads.delete(inbox, id)` | `DELETE /v1/inboxes/{inbox}/threads/{id}` |
476
+ | `extrovert.webhooks.register()` | `POST /v1/webhooks` |
477
+ | `extrovert.domains.onboard(...)` | `POST /v1/domains` |
478
+ | `extrovert.commerce.quoteDomain(...)` | `POST /v1/commerce/domain-quotes` |
479
+ | `extrovert.commerce.requestDomainPurchase(...)` | `POST /v1/commerce/requests/domain-purchases` |
480
+ | `extrovert.commerce.requestPlanChange(...)` | `POST /v1/commerce/requests/plan-changes` |
481
+ | `extrovert.commerce.get(...)` | `GET /v1/commerce/requests/{id}` |
482
+ | `extrovert.whoami()` | `GET /v1/auth/me` |
483
+
484
+ Helpers: `verifyWebhookSignature`, `parseWebhook`, `extractOtp`, `extractLink`,
485
+ `extractCredentials`, `MockBackend`.
486
+
487
+ ### Inbox quota and deletion
488
+
489
+ `extrovert.inboxes.update(address, { daily_send_limit: 250 })` and the
490
+ project-prefixed mirror set the inbox's effective rolling-24-hour recipient cap.
491
+ The value must be an integer from 1 through 10,000, and the key needs the
492
+ opt-in `mailbox:quota` scope. The returned `Inbox.daily_send_limit` is the cap
493
+ the service will enforce.
494
+
495
+ `extrovert.inboxes.delete(address)` and its project-prefixed mirror require
496
+ `mailbox:delete`. Deletion permanently removes the inbox, its messages, and its
497
+ sender identity; it cannot be undone or recovered.
498
+
499
+ ### Scopes
500
+
501
+ A key carries a subset of these capability scopes (`whoami().scopes`):
502
+
503
+ | Scope | Grants |
504
+ | ----------------- | ---------------------------------------------------------------------- |
505
+ | `mailbox:create` | Create inboxes. |
506
+ | `mailbox:read` | Read inboxes, messages, threads. |
507
+ | `mailbox:credentials` | Export raw IMAP/SMTP credentials on paid plans; never implied by read. |
508
+ | `mailbox:send` | Send / reply / forward. |
509
+ | `mailbox:quota` | Change an inbox's effective daily recipient cap (opt-in). |
510
+ | `mailbox:delete` | Delete inboxes. |
511
+ | `webhook:write` | Register / manage webhooks. |
512
+ | `domain:manage` | Onboard / verify / offboard shared or customer-controlled domains. It never buys a domain. |
513
+ | `domain:read` | Read accessible domains, readiness, and domain updates without permission to change setup. |
514
+ | `commerce:request` | Quote and request a domain purchase or plan change, then poll status. It never approves or spends directly. |
515
+ | `review:act` | The BYO reviewer decision plane. |
516
+
517
+ > The `mailbox:*` scope strings are the live wire contract (the public product term is **inbox**;
518
+ > the scope strings are kept verbatim so already-issued keys stay valid).
519
+
520
+ ---
521
+
522
+ ## Review Loop: the open contract
523
+
524
+ The **Review Loop** (HITL) adds supervised autonomy: an agent submits a draft `mode:"review"`,
525
+ a human approves / edits / rejects it, and the loop learns. The stable agent-facing JSON shapes
526
+ are published here as a documented, **versioned open contract**: an SDK + skill contract, **not**
527
+ a wire protocol (there is no `/v1/contract` endpoint).
528
+
529
+ ```ts
530
+ import { CONTRACT_VERSION, CONTRACT_MANIFEST } from "@extrovert.dev/sdk";
531
+
532
+ CONTRACT_VERSION; // "0.1.0-pre.10": provisional, pre-1.0; pin it
533
+ CONTRACT_MANIFEST.stability; // "provisional"
534
+ CONTRACT_MANIFEST.core_shapes; // ["ReviewIntent","ReviewFeedback","DiffJson","Rule","ReviewEvent"]
535
+ ```
536
+
537
+ **Rule layering (org / project).** A `Rule` carries a `rule_layer` (`"org" | "project"`) plus
538
+ `org_id` / `project_id`. `org` rules are house-style inherited by every project in the org;
539
+ `project` rules are layered on top and outrank broader org rules in the ordered `get_rules`
540
+ precedence ladder. An agent-plane `rules.save(...)` is **always project-layer** (bound to the key's
541
+ project); an agent **cannot author `rule_layer: "org"` rules in v1**: that is a console/admin
542
+ action. (`scope: "general"` still means a house-style rule *within* the project layer: `scope` is
543
+ the category axis, `rule_layer` is the ownership axis.)
544
+
545
+ Full reference: the [agent contract](https://docs.extrovert.dev/review-loop/agent-contract/) docs
546
+ page and the agent skills (`extrovert-send-email`, `extrovert-writing-rules`).
547
+
548
+ ### Contract & versioning
549
+
550
+ The Review-Loop shapes are an **open, documented contract: versioned *with* this SDK** (not a wire
551
+ protocol; there is no `/v1/contract` endpoint). Three guarantees:
552
+
553
+ - **One version, everywhere.** `CONTRACT_VERSION` is **`0.1.0-pre.10`**, reconciled across the SDK package
554
+ version, the MCP server, and the OpenAPI `info.version`. Pin it; pin `CONTRACT_MANIFEST` for the
555
+ exact shape set you built against.
556
+ - **Named, documented types.** The five canonical shapes: `ReviewIntent`, `ReviewFeedback`,
557
+ `DiffJson` (+`DiffHunk`), `Rule`, `ReviewEvent`: plus the full M1–M8 surface (submit/states,
558
+ chat, categories, graduation + risk dial, reconciliation + pacing, rules + audit, the BYO reviewer
559
+ decision plane) are exported as named TypeScript types from the `contract` module.
560
+ `CONTRACT_MANIFEST.shapes` enumerates them all by name.
561
+ - **Drift-proof.** A conformance/drift test validates the canonical example JSON against **both** the
562
+ OpenAPI component schemas (Go) and these SDK types, and asserts the version is reconciled across
563
+ every surface (plus a negative test so the guard isn't a tautology). Rename a field anywhere and
564
+ the build breaks: the published types can't silently diverge from the wire.
565
+
566
+ > **Provisional 0.x.** The contract is open and documented but MAY still evolve additively before
567
+ > 1.0 (no external users yet). Pin `CONTRACT_VERSION` and `CONTRACT_MANIFEST`.
568
+
569
+ ---
570
+
571
+ ## Examples
572
+
573
+ Runnable with [`tsx`](https://github.com/privatenumber/tsx): work offline out of the box:
574
+
575
+ ```bash
576
+ EXTROVERT_API_BASE_URL=mock npx tsx examples/mailbox-in-one-call.ts
577
+ EXTROVERT_API_BASE_URL=mock npx tsx examples/wait-for-otp.ts
578
+ ```
579
+
580
+ ---
581
+
582
+ ## Status
583
+
584
+ > **Note.** This source SDK tracks the `/v1` contract at `CONTRACT_VERSION` `0.1.0-pre.10`: a
585
+ > deliberate **prerelease**, pre-1.0, expect additive change. The offline `mock` transport models the
586
+ > live server closely enough to reproduce a 422 `intent_required` and a queued review, so build and
587
+ > test against it before you have a key. Install from the `next` tag until a stable release is cut.
588
+
589
+ ---
590
+
591
+ MIT © Message Science. *A side gate for agents.*