@extrovert.dev/sdk 0.1.0-pre.3
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 +21 -0
- package/README.md +504 -0
- package/dist/index.cjs +5286 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +3782 -0
- package/dist/index.d.ts +3782 -0
- package/dist/index.js +5218 -0
- package/dist/index.js.map +1 -0
- package/package.json +68 -0
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,504 @@
|
|
|
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.** `agent7@smtp.extrovert.dev`, sending and receiving, on a
|
|
12
|
+
pre-verified `smtp.extrovert.dev` subdomain — no DNS, no waiting.
|
|
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; mint 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.3` 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
|
+
```ts
|
|
54
|
+
import { Extrovert, isQueuedForReview } from "@extrovert.dev/sdk";
|
|
55
|
+
|
|
56
|
+
const extrovert = new Extrovert({ apiKey: process.env.EXTROVERT_API_KEY! });
|
|
57
|
+
|
|
58
|
+
// One call. A real, send-and-receive-capable inbox.
|
|
59
|
+
const inbox = await extrovert.inboxes.create({ display_name: "Support Bot" });
|
|
60
|
+
console.log(inbox.address); // agent7@smtp.extrovert.dev
|
|
61
|
+
|
|
62
|
+
// Queue a message for a human. `intent.summary` is what the reviewer reads
|
|
63
|
+
// first, and it is REQUIRED — under the default `require_review` policy a send
|
|
64
|
+
// without one is refused 422 `intent_required` (nothing sent, nothing queued).
|
|
65
|
+
const outcome = await inbox.send({
|
|
66
|
+
to: "ops@acme.test",
|
|
67
|
+
subject: "agent online",
|
|
68
|
+
text: "Reporting in.",
|
|
69
|
+
intent: { summary: "Tell ops the support agent is live and invite a reply." },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
if (isQueuedForReview(outcome)) {
|
|
73
|
+
// The normal outcome. Nothing is delivered until a human approves.
|
|
74
|
+
console.log(outcome.review.id); // rr_...
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**Your agent queues mail; it does not send it.** A message parks as a review request, a human
|
|
79
|
+
approves / edits / rejects it, and your agent watches for that outcome and redrafts as needed.
|
|
80
|
+
`allow_direct` inboxes and graduated categories are the exceptions, not the default — read
|
|
81
|
+
`inbox.record?.effective_review_policy` once rather than learning the policy by being refused. The
|
|
82
|
+
other half of the loop is the [agent contract](https://docs.extrovert.dev/review-loop/agent-contract/).
|
|
83
|
+
|
|
84
|
+
`inboxes.create()` returns an `InboxHandle` — an ergonomic handle bound to one address, so the rest
|
|
85
|
+
of your agent code reads naturally: `inbox.send(...)`, `inbox.messages()`, `inbox.waitForEmail(...)`,
|
|
86
|
+
`inbox.delete()`. This is the curl-style sugar that resolves to your key's default project.
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## The canonical chain — `projects.inboxes.*`
|
|
91
|
+
|
|
92
|
+
Scope lives in your **key**, not in headers. A broad (org-tier) key narrows to one **project** by
|
|
93
|
+
path; a project/inbox key is already pinned. The canonical, contract-aligned surface is the
|
|
94
|
+
`projects.inboxes.*` chain, keyed by the **opaque `inbox_id`** (the inbox address is accepted as a
|
|
95
|
+
within-project alias):
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
const x = new Extrovert({ apiKey: process.env.EXTROVERT_API_KEY! });
|
|
99
|
+
const { project_id } = await x.whoami(); // the key's fixed project
|
|
100
|
+
|
|
101
|
+
// Create / send / list in a project — keyed by the opaque inbox_id.
|
|
102
|
+
const inbox = await x.projects.inboxes.create(project_id!, { username: "ada" });
|
|
103
|
+
await x.projects.inboxes.send(project_id!, inbox.id, { to: "ops@acme.test", subject: "hi", text: "…",
|
|
104
|
+
intent: { summary: "…one sentence for the human reviewer…" } }); // queues for review
|
|
105
|
+
|
|
106
|
+
// One list envelope: { object: "list", data, has_more, next_cursor }. The ListPage
|
|
107
|
+
// auto-paginates over OPAQUE cursors — never thread a cursor by hand.
|
|
108
|
+
const page = await x.projects.inboxes.list(project_id!, { limit: 50 });
|
|
109
|
+
for await (const ib of page) console.log(ib.id); // walks every page
|
|
110
|
+
const all = await (await x.projects.inboxes.list(project_id!)).collect(); // eager
|
|
111
|
+
|
|
112
|
+
// Expand relations (per-resource allowlist, depth ≤ 2):
|
|
113
|
+
await x.projects.inboxes.get(project_id!, inbox.id, { include: ["agent", "domain"] });
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
An **org-tier** key can fan out across its subtree with the `-` wildcard
|
|
117
|
+
(`x.projects.inboxes.list("-")`); a non-org key on the wildcard is a `forbidden_scope` 403, and an
|
|
118
|
+
org key on a bare list is a `breadth_required` 400 (see [Errors](#errors)). The advisory
|
|
119
|
+
`x.keyTier` (`org` | `project` | `inbox`) is derived from your key prefix so you can branch before a
|
|
120
|
+
round-trip.
|
|
121
|
+
|
|
122
|
+
The SDK pins a dated **`Extrovert-Version`** header on every request (default `x.apiVersion`, the
|
|
123
|
+
latest this SDK was built against); pin an older dated version with `new Extrovert({ apiVersion })`
|
|
124
|
+
to opt into the server's transform shim.
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## The OTP flow — `waitForEmail`
|
|
129
|
+
|
|
130
|
+
Agents sign up for things. The high-value, time-boxed task is "wait for the verification email and
|
|
131
|
+
read the code." Extrovert holds the request open, polls the mailbox server-side, and hands you the extracted code.
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
import { Extrovert } from "@extrovert.dev/sdk";
|
|
135
|
+
|
|
136
|
+
const extrovert = new Extrovert({ apiKey: process.env.EXTROVERT_API_KEY! });
|
|
137
|
+
const inbox = await extrovert.inboxes.create({ username: "signup-agent" });
|
|
138
|
+
|
|
139
|
+
// ... trigger a sign-up that emails an OTP to inbox.address ...
|
|
140
|
+
await fetch("https://acme.test/signup", {
|
|
141
|
+
method: "POST",
|
|
142
|
+
body: JSON.stringify({ email: inbox.address }),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Block until it lands (up to 2 min), then read the structured result.
|
|
146
|
+
const result = await inbox.waitForEmail({
|
|
147
|
+
from: "no-reply@acme.test",
|
|
148
|
+
subject: "verification",
|
|
149
|
+
timeout_seconds: 120,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
if (result.timed_out) throw new Error("no email in time");
|
|
153
|
+
|
|
154
|
+
console.log(result.extracted.otp); // "492013"
|
|
155
|
+
console.log(result.extracted.link); // "https://acme.test/verify?token=..."
|
|
156
|
+
|
|
157
|
+
// ... submit result.extracted.otp back to the form ...
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`extracted.otp` and `extracted.link` come from the same extraction machinery that has pulled OTPs out
|
|
161
|
+
of real warmup mail for years. Need it standalone? Import `extractOtp` / `extractLink` /
|
|
162
|
+
`extractCredentials`.
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## Try it offline (no API key, no network)
|
|
167
|
+
|
|
168
|
+
Run the whole SDK against built-in, deterministic fixtures — no key, no network. Every method
|
|
169
|
+
works, including a synthesized `waitForEmail` OTP, and the mock models the real review policy,
|
|
170
|
+
so a send without an `intent` is refused offline exactly as it would be live:
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
const extrovert = new Extrovert({ transport: "mock" });
|
|
174
|
+
const inbox = await extrovert.inboxes.create();
|
|
175
|
+
const { extracted } = await inbox.waitForEmail();
|
|
176
|
+
console.log(extracted.otp); // a fixture OTP — no network touched
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Set `EXTROVERT_API_BASE_URL=mock` to flip every client into offline mode from the environment.
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## Scoped keys — redeem an enrollment key
|
|
184
|
+
|
|
185
|
+
The identity model: a human (or org-admin call) **mints** a scoped `pk_enroll_...` enrollment key
|
|
186
|
+
that can create up to *N* inboxes and nothing else. An agent **redeems** it for a short-lived,
|
|
187
|
+
individually-revocable `pk_agent_...` key.
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
// The agent is handed only the enrollment key — never an org-wide key.
|
|
191
|
+
const bootstrap = new Extrovert({ apiKey: process.env.EXTROVERT_ENROLLMENT_KEY! });
|
|
192
|
+
|
|
193
|
+
const { client, enrollment } = await bootstrap.enrolled({
|
|
194
|
+
token: process.env.EXTROVERT_ENROLLMENT_KEY!, // the raw pk_enroll_... token (required)
|
|
195
|
+
agent_handle: "support-bot", // idempotent: same handle -> same agent
|
|
196
|
+
agent_name: "Support Bot", // optional human-readable label
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// EnrollResult carries agent_id, agent_key (shown once), scopes, org_id, project_id.
|
|
200
|
+
console.log(enrollment.agent_id, enrollment.scopes);
|
|
201
|
+
console.log(enrollment.org_id, enrollment.project_id); // the key's fixed org + project
|
|
202
|
+
|
|
203
|
+
// `client` is already authenticated with the minted agent key.
|
|
204
|
+
const inbox = await client.inboxes.create();
|
|
205
|
+
|
|
206
|
+
// The minted key is bound to a fixed org + project — visible via whoami, never selectable.
|
|
207
|
+
const me = await client.whoami();
|
|
208
|
+
console.log(me.org_id, me.project_id, me.scopes);
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## Inbox metadata — attach your own key-value data
|
|
214
|
+
|
|
215
|
+
Every inbox carries an arbitrary `metadata` object (string / number / boolean values; ≤256 keys,
|
|
216
|
+
≤256 chars per key and per string value). Set it at create time, read it on every inbox shape, and
|
|
217
|
+
patch it in place with shallow-merge / null-delete semantics — no delete+recreate.
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
// Set metadata at create time. It is echoed back on the inbox record.
|
|
221
|
+
const inbox = await extrovert.inboxes.create({
|
|
222
|
+
username: "support",
|
|
223
|
+
metadata: { team: "growth", tier: 2, vip: true },
|
|
224
|
+
});
|
|
225
|
+
console.log(inbox.metadata); // { team: "growth", tier: 2, vip: true }
|
|
226
|
+
|
|
227
|
+
// PATCH semantics on update():
|
|
228
|
+
// - omitting `metadata` leaves it unchanged
|
|
229
|
+
// - an object MERGES (set/overwrite the given keys)
|
|
230
|
+
// - a key whose value is `null` DELETES that key
|
|
231
|
+
// - top-level `metadata: null` CLEARS all metadata (reads back as {})
|
|
232
|
+
const updated = await extrovert.inboxes.update(inbox.address, {
|
|
233
|
+
metadata: { tier: 3, team: null }, // bump tier, delete team
|
|
234
|
+
});
|
|
235
|
+
console.log(updated.metadata); // { tier: 3, vip: true }
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Metadata is **project-scoped** — a key only reads/mutates inboxes in its bound project. Where the
|
|
239
|
+
API accepts a `project_id`, it is an **assertion** that must match the key's bound project (a
|
|
240
|
+
mismatch is a 403), never a selector: the project is always derived from the key. See `whoami` for
|
|
241
|
+
the fixed `org_id` / `project_id` the key is bound to.
|
|
242
|
+
|
|
243
|
+
---
|
|
244
|
+
|
|
245
|
+
## Receiving mail — read, thread, reply
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
// List messages and threads in an inbox.
|
|
249
|
+
const { data: messages } = await inbox.messages({ direction: "inbound" });
|
|
250
|
+
const { data: threads } = await inbox.threads();
|
|
251
|
+
|
|
252
|
+
// Reply in-thread (In-Reply-To / References handled server-side from IMAP).
|
|
253
|
+
const first = messages[0];
|
|
254
|
+
if (first) {
|
|
255
|
+
await extrovert.messages.reply(first.id, { text: "On it — thanks." });
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
261
|
+
## Webhooks — verified inbound, anywhere
|
|
262
|
+
|
|
263
|
+
Register an HMAC-signed, timestamped webhook, then verify deliveries with Web Crypto (works in Node
|
|
264
|
+
and at the edge, no dependency):
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
import { Extrovert, verifyWebhookSignature } from "@extrovert.dev/sdk";
|
|
268
|
+
|
|
269
|
+
const extrovert = new Extrovert({ apiKey: process.env.EXTROVERT_API_KEY! });
|
|
270
|
+
|
|
271
|
+
const webhook = await extrovert.webhooks.register({
|
|
272
|
+
url: "https://my-agent.example.com/inbound",
|
|
273
|
+
events: ["message.received"],
|
|
274
|
+
});
|
|
275
|
+
// Store webhook.secret now — it is shown once.
|
|
276
|
+
|
|
277
|
+
// In your handler (Workers / Vercel Edge / Node):
|
|
278
|
+
export async function POST(req: Request) {
|
|
279
|
+
const payload = await req.text(); // raw body — do not re-serialize
|
|
280
|
+
const ok = await verifyWebhookSignature({
|
|
281
|
+
payload,
|
|
282
|
+
signature: req.headers.get("x-extrovert-signature")!,
|
|
283
|
+
secret: process.env.EXTROVERT_WEBHOOK_SECRET!,
|
|
284
|
+
});
|
|
285
|
+
if (!ok) return new Response("bad signature", { status: 400 });
|
|
286
|
+
// ... handle the verified message.received event ...
|
|
287
|
+
return new Response("ok");
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Or `parseWebhook({ ... })` to verify and JSON-parse in one step (returns `null` on a bad signature).
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
## Configuration
|
|
296
|
+
|
|
297
|
+
```ts
|
|
298
|
+
new Extrovert({
|
|
299
|
+
apiKey: "pk_agent_...", // or env EXTROVERT_API_KEY
|
|
300
|
+
baseUrl: "https://api.extrovert.dev", // or env EXTROVERT_API_BASE_URL; "mock" for offline
|
|
301
|
+
transport: "http", // "mock" to force offline fixtures
|
|
302
|
+
timeoutMs: 30_000, // default request timeout (waitForEmail manages its own)
|
|
303
|
+
retry: { maxRetries: 2, baseDelayMs: 250, maxDelayMs: 8_000 }, // idempotent 429/5xx/network
|
|
304
|
+
fetch: customFetch, // inject a fetch (tests, proxies, instrumentation)
|
|
305
|
+
defaultHeaders: { "X-Tenant": "acme" },
|
|
306
|
+
});
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
| Option | Env | Default |
|
|
310
|
+
| ------------ | ----------------------- | ------------------------------------ |
|
|
311
|
+
| `apiKey` | `EXTROVERT_API_KEY` | — (required for `http` transport) |
|
|
312
|
+
| `baseUrl` | `EXTROVERT_API_BASE_URL` | `https://api.extrovert.dev` |
|
|
313
|
+
| `transport` | (`baseUrl=mock`) | `http` |
|
|
314
|
+
| `timeoutMs` | — | `30000` |
|
|
315
|
+
|
|
316
|
+
Idempotency: pass `client_id` to `inboxes.create()` and `idempotency_key` to `send` / `reply` —
|
|
317
|
+
retries won't duplicate. Cursor pagination: list responses carry `next_cursor`; pass it back as
|
|
318
|
+
`cursor`.
|
|
319
|
+
|
|
320
|
+
---
|
|
321
|
+
|
|
322
|
+
## Errors
|
|
323
|
+
|
|
324
|
+
Every non-2xx response throws a typed error extending `ApiError`. Branch on the class or `.code`:
|
|
325
|
+
|
|
326
|
+
```ts
|
|
327
|
+
import {
|
|
328
|
+
ApiError,
|
|
329
|
+
AuthenticationError, // 401 — key missing / expired / revoked
|
|
330
|
+
PermissionError, // 403 — scope denied
|
|
331
|
+
ForbiddenScopeError, // 403 forbidden_scope — out of the key's ceiling / non-org key on the wildcard
|
|
332
|
+
BreadthRequiredError, // 400 breadth_required — org key on a bare list must pick a project / "-"
|
|
333
|
+
NotFoundError, // 404 — incl. an out-of-ceiling id (never an existence oracle)
|
|
334
|
+
ConflictError, // 409 — incl. idempotency_conflict (same key, different body)
|
|
335
|
+
ValidationError, // 422 — see err.body.error.details
|
|
336
|
+
PaymentRequiredError, // 402 — x402 test-mode challenge in err.paymentRequired
|
|
337
|
+
RateLimitError, // 429 — err.retryAfter (seconds)
|
|
338
|
+
ConnectionError, // network failure before a response
|
|
339
|
+
TimeoutError, // request timed out / aborted
|
|
340
|
+
} from "@extrovert.dev/sdk";
|
|
341
|
+
|
|
342
|
+
try {
|
|
343
|
+
await x.projects.inboxes.list("-");
|
|
344
|
+
} catch (err) {
|
|
345
|
+
if (err instanceof RateLimitError) {
|
|
346
|
+
await sleep((err.retryAfter ?? 1) * 1000);
|
|
347
|
+
} else if (err instanceof ApiError) {
|
|
348
|
+
// The redesigned surface returns RFC-9457 problem+json; switch on the CLOSED code union.
|
|
349
|
+
switch (err.problemCode) {
|
|
350
|
+
case "forbidden_scope": /* pick your own project */ break;
|
|
351
|
+
case "breadth_required": /* err.problem.errors names the next call */ break;
|
|
352
|
+
default: console.error(err.status, err.code, err.problem?.detail, err.requestId);
|
|
353
|
+
}
|
|
354
|
+
} else {
|
|
355
|
+
throw err;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
Every `ApiError` carries `status`, `code`, `requestId`, `body`, and `isClientError` / `isServerError`.
|
|
361
|
+
On the redesigned surface it additionally carries the parsed RFC-9457 `problem` and the typed
|
|
362
|
+
`problemCode` (the closed `ProblemCode` union: `bad_request`, `unauthorized`, `forbidden_scope`,
|
|
363
|
+
`not_found`, `conflict`, `idempotency_conflict`, `breadth_required`, `quota_exceeded`, `rate_limited`,
|
|
364
|
+
`domain_not_allowed`, `recipient_blocked`, `not_configured`, `domain_unavailable`, `internal`). The
|
|
365
|
+
legacy `{ error, message }` envelope is still parsed for back-compat. GET and DELETE requests retry
|
|
366
|
+
automatically on 429/5xx/network errors with jittered backoff that honors `Retry-After`.
|
|
367
|
+
|
|
368
|
+
---
|
|
369
|
+
|
|
370
|
+
## API surface
|
|
371
|
+
|
|
372
|
+
Maps 1:1 to the Extrovert `/v1` REST contract.
|
|
373
|
+
|
|
374
|
+
| SDK | Endpoint |
|
|
375
|
+
| ------------------------------------- | ------------------------------------- |
|
|
376
|
+
| `extrovert.enroll()` / `.enrolled()` | `POST /v1/enroll` |
|
|
377
|
+
| `x.projects.inboxes.create(p, ...)` | `POST /v1/projects/{project_id}/inboxes` |
|
|
378
|
+
| `x.projects.inboxes.list(p, ...)` | `GET /v1/projects/{project_id}/inboxes` (List envelope) |
|
|
379
|
+
| `x.projects.inboxes.get(p, inbox_id)` | `GET /v1/projects/{project_id}/inboxes/{inbox_id}` |
|
|
380
|
+
| `x.projects.inboxes.update(p, inbox_id)`| `PATCH /v1/projects/{project_id}/inboxes/{inbox_id}` |
|
|
381
|
+
| `x.projects.inboxes.delete(p, inbox_id)`| `DELETE /v1/projects/{project_id}/inboxes/{inbox_id}`|
|
|
382
|
+
| `x.projects.inboxes.credentials(...)` | `GET /v1/projects/{project_id}/inboxes/{inbox_id}/credentials` |
|
|
383
|
+
| `extrovert.inboxes.create()` *(sugar)* | `POST /v1/inboxes` |
|
|
384
|
+
| `extrovert.inboxes.list()` *(sugar)* | `GET /v1/inboxes` |
|
|
385
|
+
| `extrovert.inboxes.get(addr)` *(sugar)* | `GET /v1/inboxes/{inbox_id}` |
|
|
386
|
+
| `extrovert.inboxes.delete(addr)` *(sugar)* | `DELETE /v1/inboxes/{inbox_id}` |
|
|
387
|
+
| `inbox.send()` | `POST /v1/inboxes/{addr}/send` |
|
|
388
|
+
| `inbox.messages()` | `GET /v1/inboxes/{addr}/messages` |
|
|
389
|
+
| `inbox.threads()` | `GET /v1/inboxes/{addr}/threads` |
|
|
390
|
+
| `inbox.waitForEmail()` | `POST /v1/inboxes/{addr}/wait` |
|
|
391
|
+
| `extrovert.inboxes.update(addr, ...)` | `PATCH /v1/inboxes/{addr}` |
|
|
392
|
+
| `extrovert.messages.get(id)` | `GET /v1/messages/{id}` |
|
|
393
|
+
| `extrovert.messages.reply(id, ...)` | `POST /v1/messages/{id}/reply` |
|
|
394
|
+
| `extrovert.threads.get(id)` | `GET /v1/threads/{id}` |
|
|
395
|
+
| `extrovert.webhooks.register()` | `POST /v1/webhooks` |
|
|
396
|
+
| `extrovert.domains.onboard(...)` | `POST /v1/domains` |
|
|
397
|
+
| `extrovert.whoami()` | `GET /v1/auth/me` |
|
|
398
|
+
|
|
399
|
+
Helpers: `verifyWebhookSignature`, `parseWebhook`, `extractOtp`, `extractLink`,
|
|
400
|
+
`extractCredentials`, `MockBackend`.
|
|
401
|
+
|
|
402
|
+
### Inbox quota and deletion
|
|
403
|
+
|
|
404
|
+
`extrovert.inboxes.update(address, { daily_send_limit: 250 })` and the
|
|
405
|
+
project-prefixed mirror set the inbox's effective rolling-24-hour recipient cap.
|
|
406
|
+
The value must be an integer from 1 through 10,000, and the key needs the
|
|
407
|
+
opt-in `mailbox:quota` scope. The returned `Inbox.daily_send_limit` is the cap
|
|
408
|
+
the service will enforce.
|
|
409
|
+
|
|
410
|
+
`extrovert.inboxes.delete(address)` and its project-prefixed mirror require
|
|
411
|
+
`mailbox:delete`. Deletion permanently removes the inbox, its messages, and its
|
|
412
|
+
sender identity; it cannot be undone or recovered.
|
|
413
|
+
|
|
414
|
+
### Scopes
|
|
415
|
+
|
|
416
|
+
A key carries a subset of these capability scopes (`whoami().scopes`):
|
|
417
|
+
|
|
418
|
+
| Scope | Grants |
|
|
419
|
+
| ----------------- | ---------------------------------------------------------------------- |
|
|
420
|
+
| `mailbox:create` | Create inboxes. |
|
|
421
|
+
| `mailbox:read` | Read inboxes, messages, threads. |
|
|
422
|
+
| `mailbox:send` | Send / reply / forward. |
|
|
423
|
+
| `mailbox:quota` | Change an inbox's effective daily recipient cap (opt-in). |
|
|
424
|
+
| `mailbox:delete` | Delete inboxes. |
|
|
425
|
+
| `webhook:write` | Register / manage webhooks. |
|
|
426
|
+
| `domain:manage` | The agent-facing domains plane (onboard / verify / offboard). |
|
|
427
|
+
| `domain:purchase` | Additionally required (and **opt-in, default-off**) to onboard with `mode: "purchased"` (registrar spend, plan-capped). |
|
|
428
|
+
| `review:act` | The BYO reviewer decision plane. |
|
|
429
|
+
|
|
430
|
+
> The `mailbox:*` scope strings are the live wire contract (the public product term is **inbox**;
|
|
431
|
+
> the scope strings are kept verbatim so already-minted keys stay valid).
|
|
432
|
+
|
|
433
|
+
---
|
|
434
|
+
|
|
435
|
+
## Review Loop — the open contract
|
|
436
|
+
|
|
437
|
+
The **Review Loop** (HITL) adds supervised autonomy: an agent submits a draft `mode:"review"`,
|
|
438
|
+
a human approves / edits / rejects it, and the loop learns. The stable agent-facing JSON shapes
|
|
439
|
+
are published here as a documented, **versioned open contract** — an SDK + skill contract, **not**
|
|
440
|
+
a wire protocol (there is no `/v1/contract` endpoint).
|
|
441
|
+
|
|
442
|
+
```ts
|
|
443
|
+
import { CONTRACT_VERSION, CONTRACT_MANIFEST } from "@extrovert.dev/sdk";
|
|
444
|
+
|
|
445
|
+
CONTRACT_VERSION; // "0.1.0-pre.3" — provisional, pre-1.0; pin it
|
|
446
|
+
CONTRACT_MANIFEST.stability; // "provisional"
|
|
447
|
+
CONTRACT_MANIFEST.core_shapes; // ["ReviewIntent","ReviewFeedback","DiffJson","Rule","ReviewEvent"]
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
**Rule layering (org / project).** A `Rule` carries a `rule_layer` (`"org" | "project"`) plus
|
|
451
|
+
`org_id` / `project_id`. `org` rules are house-style inherited by every project in the org;
|
|
452
|
+
`project` rules are layered on top and outrank broader org rules in the ordered `get_rules`
|
|
453
|
+
precedence ladder. An agent-plane `rules.save(...)` is **always project-layer** (bound to the key's
|
|
454
|
+
project); an agent **cannot author `rule_layer: "org"` rules in v1** — that is a console/admin
|
|
455
|
+
action. (`scope: "general"` still means a house-style rule *within* the project layer — `scope` is
|
|
456
|
+
the category axis, `rule_layer` is the ownership axis.)
|
|
457
|
+
|
|
458
|
+
Full reference: the [agent contract](https://docs.extrovert.dev/review-loop/agent-contract/) docs
|
|
459
|
+
page and the agent skills (`extrovert-send-email`, `extrovert-writing-rules`).
|
|
460
|
+
|
|
461
|
+
### Contract & versioning
|
|
462
|
+
|
|
463
|
+
The Review-Loop shapes are an **open, documented contract — versioned *with* this SDK** (not a wire
|
|
464
|
+
protocol; there is no `/v1/contract` endpoint). Three guarantees:
|
|
465
|
+
|
|
466
|
+
- **One version, everywhere.** `CONTRACT_VERSION` is **`0.1.0-pre.3`**, reconciled across the SDK package
|
|
467
|
+
version, the MCP server, and the OpenAPI `info.version`. Pin it; pin `CONTRACT_MANIFEST` for the
|
|
468
|
+
exact shape set you built against.
|
|
469
|
+
- **Named, documented types.** The five canonical shapes — `ReviewIntent`, `ReviewFeedback`,
|
|
470
|
+
`DiffJson` (+`DiffHunk`), `Rule`, `ReviewEvent` — plus the full M1–M8 surface (submit/states,
|
|
471
|
+
chat, categories, graduation + risk dial, reconciliation + pacing, rules + audit, the BYO reviewer
|
|
472
|
+
decision plane) are exported as named TypeScript types from the `contract` module.
|
|
473
|
+
`CONTRACT_MANIFEST.shapes` enumerates them all by name.
|
|
474
|
+
- **Drift-proof.** A conformance/drift test validates the canonical example JSON against **both** the
|
|
475
|
+
OpenAPI component schemas (Go) and these SDK types, and asserts the version is reconciled across
|
|
476
|
+
every surface (plus a negative test so the guard isn't a tautology). Rename a field anywhere and
|
|
477
|
+
the build breaks — the published types can't silently diverge from the wire.
|
|
478
|
+
|
|
479
|
+
> **Provisional 0.x.** The contract is open and documented but MAY still evolve additively before
|
|
480
|
+
> 1.0 (no external users yet). Pin `CONTRACT_VERSION` and `CONTRACT_MANIFEST`.
|
|
481
|
+
|
|
482
|
+
---
|
|
483
|
+
|
|
484
|
+
## Examples
|
|
485
|
+
|
|
486
|
+
Runnable with [`tsx`](https://github.com/privatenumber/tsx) — work offline out of the box:
|
|
487
|
+
|
|
488
|
+
```bash
|
|
489
|
+
EXTROVERT_API_BASE_URL=mock npx tsx examples/mailbox-in-one-call.ts
|
|
490
|
+
EXTROVERT_API_BASE_URL=mock npx tsx examples/wait-for-otp.ts
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
---
|
|
494
|
+
|
|
495
|
+
## Status
|
|
496
|
+
|
|
497
|
+
> **Note.** This source SDK tracks the `/v1` contract at `CONTRACT_VERSION` `0.1.0-pre.3` — a
|
|
498
|
+
> deliberate **prerelease**, pre-1.0, expect additive change. The offline `mock` transport models the
|
|
499
|
+
> live server closely enough to reproduce a 422 `intent_required` and a queued review, so build and
|
|
500
|
+
> test against it before you have a key. Install from the `next` tag until a stable release is cut.
|
|
501
|
+
|
|
502
|
+
---
|
|
503
|
+
|
|
504
|
+
MIT © Message Science. *A side gate for agents.*
|