# Inkbox TS

> Use when writing TypeScript or JavaScript code that imports from `@inkbox/sdk`, uses `npm install @inkbox/sdk`, or when adding email, mailbox imports, phone, text/SMS, iMessage, A2A task/message history, contacts, notes, contact rules, vault, tunnels, mailbox storage, mail clients (IMAP/SMTP), or agent identity features using the Inkbox TypeScript SDK.

- Skill: `inkbox-ai/inkbox-ts` (Agent Skill)
- Install (CLI): `npx skillmds@latest add inkbox-ai/inkbox-ts`
- Raw SKILL.md: https://api.skillmd.com/api/skills/inkbox-ai/inkbox-ts/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: inkbox-ai (https://skillmd.com/u/inkbox-ai)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/inkbox-ai/inkbox-ts

---


# Inkbox TypeScript SDK

API-first communication infrastructure for AI agents — email, phone, encrypted vault, and identities.

## Install & Init

```bash
npm install @inkbox/sdk
```

Requires Node.js ≥ 22. ESM module — no context manager needed:

```typescript
import { Inkbox } from "@inkbox/sdk";

const inkbox = new Inkbox({ apiKey: "ApiKey_..." });
```

Constructor options: `{ apiKey: string, baseUrl?: string, timeoutMs?: number }`

## Core Model

```
Inkbox (admin-only client)
├── .createIdentity(handle)   → Promise<AgentIdentity>
├── .getIdentity(handle)      → Promise<AgentIdentity>
├── .listIdentities()         → Promise<AgentIdentitySummary[]>
├── .mailboxes                → MailboxesResource
├── .phoneNumbers             → PhoneNumbersResource
├── .texts                    → TextsResource
├── .imessages                → IMessagesResource
├── .imessageContactRules     → IMessageContactRulesResource
├── .mailIdentityContactRules  → MailIdentityContactRulesResource    (keyed by agentHandle)
├── .phoneIdentityContactRules → PhoneIdentityContactRulesResource   (keyed by agentHandle)
├── .signingKeys              → SigningKeysResource  (per-identity: createOrRotate/getStatus)
├── .mailContactRules         → MailContactRulesResource    (DEPRECATED — per-mailbox)
├── .phoneContactRules        → PhoneContactRulesResource   (DEPRECATED — per-number)
├── .smsOptIns                → SmsOptInsResource
├── .contacts                 → ContactsResource   (.communicationPolicy, .permissions, .facts, .correspondence, .access, .vcards)
├── .notes                    → NotesResource      (.access)
├── .vault                    → VaultResource
├── .whoami()                 → Promise<WhoamiResponse>
└── .createSigningKey()       → Promise<SigningKey>  (DEPRECATED — org-level; use .signingKeys)

AgentIdentity (identity-scoped helper)
├── .mailbox                → IdentityMailbox | null
├── .phoneNumber            → IdentityPhoneNumber | null
├── .mailFilterMode / .phoneFilterMode → FilterMode
├── .getCredentials()       → Promise<Credentials>  (requires vault unlocked)
├── .listMailContactRules() / .createMailContactRule(...) / .get/.update/.delete
├── .listPhoneContactRules() / .createPhoneContactRule(...) / ...  (writes require admin credentials)
├── .getSigningKeyStatus() / .createSigningKey()
├── .listContactCommunicationPolicies() → Promise<ContactCommunicationPolicyPage>
├── mail methods            (requires assigned mailbox)
├── phone methods           (requires assigned phone number)
└── text methods            (requires assigned phone number)
```

An identity must have a channel assigned before you can use mail/phone methods. If not assigned, an `InkboxError` is thrown.

## Agent Signup

For the full agent self-signup flow (register, verify, check status, restrictions, and direct API examples), read the shared reference:

> **See:** `skills/inkbox-agent-self-signup/SKILL.md`

TypeScript SDK methods: `Inkbox.signup({...})`, `Inkbox.verifySignup(apiKey, {...})`, `Inkbox.resendSignupVerification(apiKey)`, `Inkbox.getSignupStatus(apiKey)`.

## Identities

```typescript
const identity = await inkbox.createIdentity("sales-agent");
const identity = await inkbox.getIdentity("sales-agent");
const identities = await inkbox.listIdentities();   // AgentIdentitySummary[]

await identity.update({ newHandle: "new-name" });   // rename
await identity.refresh();                            // re-fetch from API, updates cached channels
await identity.delete();                             // cascades: mailbox + tunnel + phone-number release
```

## Channel Management

```typescript
// Identity is created with a mailbox AND tunnel atomically — both are on the response
console.log(identity.emailAddress);            // e.g. "sales-agent@inkboxmail.com"
console.log(identity.tunnel?.publicHost);      // e.g. "sales-agent.inkboxwire.com"

// Phone numbers are still opt-in
const phone = await identity.provisionPhoneNumber({ type: "local", state: "NY" });  // local only; toll_free is rejected (422)
console.log(phone.number);                     // e.g. "+12125551234"

// Release the phone number (vendor + local)
await identity.releasePhoneNumber();
```

Mailboxes and tunnels are not separately linkable — they are 1:1 with their owning identity. Use `inkbox.createIdentity()` to provision both; use `identity.delete()` to remove both (cascade).

## Mail

### Import historical mail

```typescript
import { MailImportFormat } from "@inkbox/sdk";

const created = await inkbox.mailboxes.imports.create(email, {
  sourceFormat: MailImportFormat.AUTO,
  originalAddresses: ["old@example.com"],
});
await inkbox.mailboxes.imports.upload(created.upload, file);
await inkbox.mailboxes.imports.start(email, created.job.id);
const job = await inkbox.mailboxes.imports.wait(email, created.job.id, {
  pollIntervalMs: 5_000,
});
```

Formats: `auto`, `mbox`, `eml`, `zip`. A ZIP may hold `.eml` and/or `.mbox`
files (a Gmail Takeout ZIP imports as-is); other entries, including nested
archives, are ignored. `wait` returns all terminal states; failure/cancellation
are job results, not transport errors. A timeout does not cancel. Counters are
cumulative and never go backwards, so a stalled counter is a signal, not normal
churn; counters may still remain unchanged while a slow message is processed,
and they must not be treated as a percentage. Jobs run one at a time per
organization and share overall import capacity, so a long `queued` stretch is
normal; do not cancel and recreate. Unsafe imported content may be rejected.

Upload targets expire after 5 minutes: `refreshUploadTarget(email, jobId)` and
upload again, or `cancel` the job so it does not hold the mailbox for 24 hours.
Limits: 1 GiB per upload, 50 MiB per message, 100,000 messages and 20
`originalAddresses` per job, 65,000 entries per ZIP, 20 jobs per organization
per 24 hours (`MailImportQuotaExceededError.retryAfterSeconds`), and one
in-flight import per mailbox.

### Send

```typescript
const sent = await identity.sendEmail({
  to: ["user@example.com"],
  subject: "Hello",
  bodyText: "Hi there!",           // plain text (optional)
  bodyHtml: "<p>Hi there!</p>",    // HTML (optional)
  cc: ["cc@example.com"],          // optional
  bcc: ["bcc@example.com"],        // optional
  inReplyToMessageId: sent.id,     // for threaded replies
  attachments: [{                  // optional
    filename: "report.pdf",
    contentType: "application/pdf",
    contentBase64: "<base64>",
  }, {
    filename: "chart.png",         // inline image: set contentId and reference
    contentType: "image/png",      // it from bodyHtml as <img src="cid:chart">.
    contentBase64: "<base64>",      // needs bodyHtml + image/*, unique per send;
    contentId: "chart",            // not on forwards. Not counted in hasAttachments.
  }],
  trackOpens: true,                // optional; embed a tracking pixel
});
// trackOpens tracks sends only when an HTML body is present. Opens surface
// on the returned Message as sent.firstOpenedAt / sent.openCount (an upper
// bound — image proxies prefetch pixels; pixels can also raise spam scores).
//
// sendEmail / replyAllEmail / forwardEmail all throw StorageLimitExceededError
// (402) when the mailbox is at its storage cap — see "Storage cap (402)" below.
```

### Drafts

```typescript
const draft = await identity.createEmailDraft({
  subject: "Work in progress",
  idempotencyKey: "draft-create-2026-08-19-1",
});
for await (const saved of identity.iterEmailDrafts()) {
  console.log(saved.id, saved.generation);
}
let current = await identity.getEmailDraft(draft.id);
current = await identity.updateEmailDraft(current.id, {
  generation: current.generation,
  recipients: { to: ["user@example.com"] },
  subject: null, // explicit null clears; omission leaves unchanged
});

current = await inkbox.drafts.addAttachments(identity.emailAddress!, current.id,
  current.generation, [{
    filename: "notes.txt",
    contentType: "text/plain",
    contentBase64: "bm90ZXM=",
  }]);
const part = current.attachmentMetadata[0];
const content = await inkbox.drafts.downloadAttachment(
  identity.emailAddress!, current.id, part.partIndex, current.generation,
);
current = await inkbox.drafts.removeAttachment(
  identity.emailAddress!, current.id, part.partIndex, current.generation,
);

const copy = await identity.duplicateEmailDraft(current.id, current.generation);
await identity.deleteEmailDraft(copy.id, copy.generation);
const sent = await identity.sendEmailDraft(current.id, current.generation);
```

Drafts share the mailbox's standard Drafts folder with connected mail clients.
Reuse one `idempotencyKey` and the exact same request when retrying a logical
create after an ambiguous result. Use a new key after the original draft is sent
or deleted. Forward-only options require `forwardMessageId`.
Use the latest returned `generation` for every mutation. A `partIndex` belongs
to the generation that returned it, so refresh attachment metadata after edits.

Successful send returns a `Message` and removes the draft; an exact-generation
retry may return the same sent message. HTTP 409 errors remain structured on
`InkboxAPIError.detail.error`: refresh on `draft_generation_conflict` and retry
the same ID and generation on `draft_send_in_progress`. Never resend
`draft_delivery_uncertain`; after checking sent mail, duplicate or delete it instead.

### Read

```typescript
// Iterate all messages — auto-paginated async generator
for await (const msg of identity.iterEmails()) {
  console.log(msg.subject, msg.fromAddress, msg.isRead);
}

// Filter by direction
for await (const msg of identity.iterEmails({ direction: "inbound" })) {   // or "outbound"
  ...
}

// Unread only (client-side filtered)
for await (const msg of identity.iterUnreadEmails()) {
  ...
}

// Mark as read
const ids: string[] = [];
for await (const msg of identity.iterUnreadEmails()) ids.push(msg.id);
await identity.markEmailsRead(ids);
await identity.markEmailsUnread(ids);   // batch counterpart
// Note: fetching a single inbound message by id (inkbox.messages.get) with
// an API key marks it read server-side; iterating does not, so
// markEmailsRead is the way to clear unread for list-only workflows. isRead
// (agent consumed via API) is distinct from firstOpenedAt (recipient's mail
// client loaded the tracking pixel).

// Get full thread (oldest-first)
const thread = await identity.getThread(msg.threadId);
for (const m of thread.messages) {
  console.log(`[${m.fromAddress}] ${m.subject}`);
}
```

### Thread Folders

Threads carry a `folder` field: `inbox`, `spam`, `archive`, or `blocked` (server-assigned, never client-set).

```typescript
import { ThreadFolder } from "@inkbox/sdk";
// thread.folder / threadDetail.folder is always one of the four values above.
```

Low-level folder listing / per-thread updates (`list({ folder })`, `listFolders(email)`, `update(..., { folder })`) live on `ThreadsResource`. Passing `folder: "blocked"` to `update` throws before the HTTP call.

### Storage cap (402)

Every mailbox has a plan storage cap. **All three send paths** — `sendEmail`, `replyAllEmail`, and `forwardEmail` (and the `inkbox.messages.*` equivalents) — throw `StorageLimitExceededError` (HTTP 402) when the send would push the mailbox over it.

```typescript
import { StorageLimitExceededError } from "@inkbox/sdk";

try {
  await identity.sendEmail({ to: ["user@example.com"], subject: "Hi", bodyText: "…" });
} catch (e) {
  if (e instanceof StorageLimitExceededError) {
    console.log(e.message);      // human sentence, includes the limit
    console.log(e.limitBytes);   // e.g. 2147483648 (2 GiB)
    console.log(e.upgradeUrl);   // console billing page
    // Free space — reclaim is immediate — or upgrade the plan:
    await inkbox.messages.delete(identity.emailAddress!, "<message-uuid>");
    await inkbox.threads.delete(identity.emailAddress!, "<thread-uuid>");
  }
}
```

Read usage off the mailbox (`inkbox.mailboxes.get(...)`): `storageUsedBytes` and `storageLimitBytes` (`null` = the server resolved no cap). The caps are **binary** — 2 GiB is `2 * 1024 ** 3` = 2,147,483,648 bytes, so divide by 1024 and label GiB/MiB, never GB.

**Free plan:** a footer is appended to the **stored** body of outgoing mail, so `inkbox.messages.get(...)` does not return byte-for-byte what you sent (a body-less send comes back with the footer as its body). Don't assert `sentBody === fetchedBody` on a Free plan.

## Mail Clients (IMAP/SMTP)

An inbox can be attached to a regular mail client (Thunderbird, Apple Mail, mutt, …) with the API key you already have — there is no separate credential to create and **no SDK call involved**; the gateway speaks IMAP and SMTP, not HTTP.

| Setting | Value |
|---|---|
| IMAP host | `imap.inkboxmail.com` |
| IMAP port | `993` (IMAPS / implicit TLS) |
| SMTP host | `smtp.inkboxmail.com` |
| SMTP port | `465` (SMTPS / implicit TLS) or `587` (STARTTLS) |
| Username | the inbox address (e.g. `sales-agent@inkboxmail.com`) |
| Password | an **identity-scoped** API key (`ApiKey_...`) |

The password is the same agent-scoped key an identity-scoped `Inkbox({...})` client authenticates with; mint one with `inkbox.apiKeys.create({ scopedIdentityId })`. Admin-scoped keys are rejected — one key maps to exactly one mailbox. Revoking the key revokes mail-client access.

Constraints that bite:

- **`From` must be the authenticated inbox address**, and exactly one address — aliases / "send as" are rejected.
- **On the Free plan, signed/encrypted mail (S/MIME, PGP) cannot be sent over SMTP** — the required footer can't be injected without breaking the signature, so the send is refused. Send unsigned, or upgrade.
- Leave "save a copy of sent messages" **on** — Inkbox recognizes the client's copy as the message it already stored, so you get one Sent entry, charged against the storage cap once.

Full walkthrough: https://inkbox.ai/docs/capabilities/email/mail-clients

## Phone

```typescript
import { CallMode, ForwardingTargetType, IncomingCallAction } from "@inkbox/sdk";

// Place outbound call — stream audio via WebSocket
const call = await identity.placeCall({
  toNumber: "+15551234567",
  clientWebsocketUrl: "wss://your-agent.example.com/ws",
});
console.log(call.status);
console.log(call.rateLimit.callsRemaining);

// Or let Inkbox Voice AI drive the call — no WebSocket,
// no code. reason is the agent's task brief (required with
// mode=hosted_agent, invalid otherwise; server 422).
const hosted = await identity.placeCall({
  toNumber: "+15551234567",
  mode: CallMode.HOSTED_AGENT,   // default CallMode.CLIENT_WEBSOCKET
  reason: "Confirm tomorrow's 3pm appointment; reschedule if needed.",
  // Optional: onVoicemail (OnVoicemail.LEAVE_MESSAGE | HANG_UP | IGNORE;
  // hosted calls default to leave_message) and voicemailMessage (what Voice
  // AI says; requires leave_message). VoicemailDetection is deprecated.
});
console.log(hosted.mode, hosted.reason, hosted.onVoicemail);
// where Voice AI isn't available (or is at capacity), the server's
// 503 (hosted_agent_unavailable / hosted_agent_at_capacity) surfaces verbatim.

// List calls (offset pagination). Every call carries mode / reason plus
// postCallActionItems — open items Voice AI recorded
// (seq-ascending; empty for client_websocket calls)
const calls = await identity.listCalls({ limit: 10, offset: 0 });
for (const c of calls) {
  console.log(c.id, c.direction, c.remotePhoneNumber, c.status, c.mode);
  for (const item of c.postCallActionItems) {
    console.log(`  [${item.seq}] ${item.action}: ${item.details}`);
  }
}

// Transcript segments (ordered by seq)
const segments = await identity.listTranscripts(calls[0].id);
for (const t of segments) {
  console.log(`[${t.party}] ${t.text}`);   // party: "local" or "remote"
}

// Hang up a live call from outside it (teardown confirms asynchronously,
// so the returned call can still show its live status; already-ended
// calls surface the server's 409)
const hungUp = await identity.hangupCall(calls[0].id);

// Organization-scoped voice discovery; no identity ID is needed.
// Entries include id, name, description, available, and optional previewUrl.
// Keep unavailable entries for display; do not hardcode a voice allowlist.
const catalog = await inkbox.hostedAgent.listVoices();
console.log(catalog.defaultVoice, catalog.voices);
const selectedVoice = catalog.voices.find((voice) => voice.available);

// Per-identity Inkbox Voice AI config: voice and instructions.
// Both are nullable (null means the server default). setHostedAgentConfig is
// a FULL REPLACE — an omitted field resets to the server default.
const cfg = await identity.getHostedAgentConfig();
if (selectedVoice) {
  await identity.setHostedAgentConfig({
    voice: selectedVoice.id,
    instructions: cfg.instructions ?? undefined, // Preserve when changing only voice.
  });
}

// Inbound-call handling: auto_accept | auto_reject | webhook | hosted_agent | forward.
// hosted_agent needs no URL; forward needs exactly one phone or SIP target.
await identity.setIncomingCallAction({
  incomingCallAction: IncomingCallAction.HOSTED_AGENT,
});
await identity.setIncomingCallAction({
  incomingCallAction: IncomingCallAction.FORWARD,
  forwardingTargetType: ForwardingTargetType.PHONE,
  forwardingPhoneNumber: "+15551234567",
});
console.log((await identity.getIncomingCallAction()).incomingCallAction);
```

## Text Messages (SMS/MMS)

**Outbound SMS limits and gates (current):**

- Allowed only from **local** numbers, not toll-free.
- **100 recipient sends per phone number per rolling 24h.** A 3-recipient group message counts as 3 recipient sends. A single accepted send may push usage past the cap; the next capped send returns `429 sender_rate_limited`.
- New local numbers need **~10-15 min** for 10DLC carrier propagation. `identity.phoneNumber.smsStatus` is `SmsStatus.PENDING` until ready; sends in this window return `409 sender_sms_pending`.
- Recipient must have texted **`START`** to any number in the org. Unknown → `403 recipient_not_opted_in`. `STOP` → `403 recipient_opted_out`. Inspect / override consent state via `inkbox.smsOptIns` (see below).
- **Beta:** Group MMS and conversation sends are beta. Some carriers may reject group chats or MMS from 10DLC numbers even when the sender is ready and recipients have opted in.

Customer-managed 10DLC brands/campaigns lift the default per-number cap to the carrier-assigned tier. Toll-free SMS sending is still coming soon.

```typescript
// Send SMS/MMS from this identity's phone number.
// Returns a queued TextMessage; final delivery state arrives via any
// webhook subscription on the sender's phone number whose eventTypes
// include the text.* lifecycle events.
const sent = await identity.sendText({
  to: "+15551234567",
  text: "Hello from Inkbox",
});
console.log(sent.id, sent.deliveryStatus);   // "queued"

// Group MMS beta: pass an array of recipients plus optional media URLs.
const group = await identity.sendText({
  to: ["+15551234567", "+15557654321"],
  text: "Hello group",
  mediaUrls: ["https://example.com/photo.jpg"],
});
console.log(group.conversationId, group.recipients);

// Reply to an existing conversation by UUID. Do not pass `to` with this form.
const reply = await identity.sendText({
  conversationId: group.conversationId,
  text: "Following up in the same conversation.",
});

// List text messages (offset pagination)
const texts = await identity.listTexts({ limit: 20, offset: 0 });
for (const t of texts) {
  console.log(t.id, t.direction, t.remotePhoneNumber, t.text, t.isRead);
}

// Filter by read state
const unread = await identity.listTexts({ isRead: false });

// Get a single text message
const text = await identity.getText("text-uuid");
console.log(text.type);   // "sms" or "mms"
if (text.media) {          // MMS media attachments (temporary signed URLs)
  for (const m of text.media) {
    console.log(m.contentType, m.size, m.url);
  }
}

// List one-to-one conversation summaries; opt into groups explicitly.
const convos = await identity.listTextConversations({ limit: 20, includeGroups: true });
for (const c of convos) {
  console.log(c.id, c.participants, c.latestHasMedia, c.latestText);
}

// Get messages in a specific conversation by remote number or conversation UUID.
const msgs = await identity.getTextConversation("+15551234567", { limit: 50 });

// Mark a text as read (identity convenience method)
await identity.markTextRead("text-uuid");

// Mark all messages in a conversation as read
const readResult = await identity.markTextConversationRead("+15551234567");
console.log(readResult.updatedCount);

// Admin-only: search, update, delete
const results = await inkbox.texts.search(phone.id, { q: "invoice", limit: 20 });
await inkbox.texts.update(phone.id, "text-uuid", { status: "deleted" });
```

## iMessage

iMessage can use shared service or an organization-owned dedicated line. Shared service requires the recipient to message first; a dedicated line can initiate one-to-one and group conversations, subject to server-side policy checks.

Discover the router (triage) line at runtime — it can change, so never hardcode it:

```typescript
const triage = await inkbox.imessages.getTriageNumber();
console.log(triage.number, triage.connectCommand);  // "+1646...", "connect @your-handle"
// Humans connect by texting that command to that number.
```

Reachability is **opt-in per identity** (`imessageEnabled`, default `false`):

```typescript
const identity = await inkbox.createIdentity("my-agent", { imessageEnabled: true });
// or toggle later
await identity.update({ imessageEnabled: true });
// admin-only: flip contact-rule mode (default "blacklist")
await identity.update({ imessageFilterMode: "whitelist" });
console.log(identity.imessageEnabled, identity.imessageFilterMode);
```

Messaging (identity convenience methods; `inkbox.imessages` is the org-level resource with the same operations plus `agentIdentityId` / `isBlocked` filters):

New identities default `contactSharingEnabled: true`. When a dedicated line
is attached, it automatically offers the identity's display name (or handle as
fallback) and optional avatar. Set `contactSharingEnabled: false` in the same
create request as `claimIMessageNumber: true` to opt out before the line is
claimed. The identity can be updated later to enable or disable sharing.

```typescript
import { IMessageSendStyle } from "@inkbox/sdk";

// Send to a connected recipient, or reply into a conversation by UUID.
const sent = await identity.sendIMessage({ to: "+15551234567", text: "Hello over iMessage" });
const dedicatedIdentity = await inkbox.createIdentity("dedicated-agent", {
  imessageEnabled: true,
  contactSharingEnabled: false, // opt out before claiming the line
  claimIMessageNumber: true,
});
await dedicatedIdentity.update({ contactSharingEnabled: true }); // enable later
const group = await dedicatedIdentity.sendIMessage({
  to: ["+15551234567", "+15557654321"],
  text: "Hello group",
  mediaUrls: ["https://example.com/group-photo.jpg"],
  sendStyle: IMessageSendStyle.CONFETTI,
}); // dedicated line only; 2–8 distinct recipients
const groupReply = await dedicatedIdentity.sendIMessage({
  conversationId: group.conversationId,
  text: "Group follow-up",
  mediaUrls: ["https://example.com/follow-up.jpg"],
  sendStyle: IMessageSendStyle.LASERS,
});
console.log(sent.service, sent.status);  // "imessage", "queued"

// List messages / conversations
const msgs = await identity.listIMessages({ limit: 20, isRead: false, includeGroups: true });
const convos = await identity.listIMessageConversations({ limit: 20, includeGroups: true });
const convo = await identity.getIMessageConversation(sent.conversationId);
// assignmentStatus tells you whether the recipient is still connected:
// anything other than "active" means sends/reactions will be refused
// until they reconnect through triage.
console.log(convo.assignmentStatus);
// Group rows have nullable assignment/remote fields and a best-known participant
// snapshot. groupCreationStatus is "creating", "not_created", or "ready". A
// rejected initial creation keeps the same conversation; send again by
// conversationId to retry, and success changes it to "ready".
// Group creation and conversationId replies accept the same 13
// IMessageSendStyle values as one-to-one sends, with or without the media URL.

// Who is actively connected to this identity right now (paginated)?
const connections = await identity.listIMessageAssignments({ limit: 20 });
await identity.releaseIMessageAssignment(connections[0].id); // admin key only; they can reconnect via triage
for (const a of connections) {
  console.log(a.remoteNumber, a.status, a.createdAt);
}

// Tapbacks target inbound one-to-one or group messages by messageId. Sends
// accept seven named reactions (love, like, dislike, laugh, emphasize,
// question, eyes); inbound can also be "custom" with the literal emoji in
// customEmoji. Arbitrary custom emoji are not sendable.
const sentReaction = await identity.sendIMessageReaction({ messageId: msgs[0].id, reaction: "like" });

// Live tapbacks come back on message reads, oldest first.
for (const r of msgs[0].reactions ?? []) {
  console.log(r.direction, r.reaction, r.customEmoji);
}

// Take your own tapback back. Only the sender can. A failed removal leaves the
// tapback in place rather than clearing it locally, so the call can be retried.
await identity.removeIMessageReaction(sentReaction.id);

// Read receipts + typing indicator are one-to-one only; groups return 409.
await identity.markIMessageConversationRead(sent.conversationId);
await identity.sendIMessageTyping(sent.conversationId);

// Media: upload bytes (max 10 MiB), then send the returned URL (one per message)
const upload = await identity.uploadIMessageMedia({
  content: await readFile("photo.jpg"),
  filename: "photo.jpg",
  contentType: "image/jpeg",
});
await identity.sendIMessage({ to: "+15551234567", mediaUrls: [upload.mediaUrl] });
```

Contact rules are scoped to the **identity**, including when it has a dedicated line:

Phone rules cover SMS, calls, and iMessage together. Creation, updates, and deletion require admin credentials. An agent key can inspect permitted rules but cannot authorize itself; a user changes permissions in the Inkbox Console.

```typescript
import { IMessageRuleAction } from "@inkbox/sdk";

const rule = await inkbox.imessageContactRules.create("my-agent", {
  action: IMessageRuleAction.BLOCK,
  matchTarget: "+15559999999",
});
const rules = await inkbox.imessageContactRules.list("my-agent");
await inkbox.imessageContactRules.update("my-agent", rule.id, {
  action: IMessageRuleAction.ALLOW,
}); // admin-only
await inkbox.imessageContactRules.delete("my-agent", rule.id);                       // admin-only
const allRules = await inkbox.imessageContactRules.listAll();                        // admin-only, org-wide
```

Inbound messages and reactions arrive via **identity-owned** webhook subscriptions — see Webhooks below.

## SMS Opt-Ins

Per-recipient SMS consent state, keyed by `(your org, recipient number)`. The registry is updated automatically when recipients text `START` / `STOP` to any of your numbers (`source: "sms"`). Reads are admin-only; writes are admin-only **and** require your org to be on its own active, customer-managed 10DLC campaign (Inkbox-default-campaign orgs share consent state and get `409 customer_campaign_required` on writes — `source: "api"` writes record an audit event).

```typescript
import { SmsOptInStatus } from "@inkbox/sdk";

// List your org's consent rows, newest-updated first (server caps limit at 200)
const rows = await inkbox.smsOptIns.list({ limit: 50 });
const optedOut = await inkbox.smsOptIns.list({ status: SmsOptInStatus.OPTED_OUT });

// Look up one recipient — 404 → InkboxAPIError if no row exists
const row = await inkbox.smsOptIns.get("+15551234567");
console.log(row.status, row.source, row.optedInAt, row.optedOutAt);

// Programmatic writes (customer-managed 10DLC campaign only)
await inkbox.smsOptIns.optIn("+15551234567");
await inkbox.smsOptIns.optOut("+15551234567");
```

## Agent-to-Agent (A2A)

**Invitations:** an admin-scoped API key uses `inkbox.a2aInvitations.create(...)`,
`.list(...)`, `.get(id)`, and `.revoke(id)`. A claimed agent-scoped key uses
`.accept(invitation)`. The value may be an exact-origin share URL or raw token;
`extractA2AInvitationToken()` performs the same strict local normalization. Unbound
create responses may reveal `invitationToken`, `invitationUrl`, and
`agentHandoffPrompt`; email-bound creates omit capability fields. Signup
accepts the same input and returns the optional `invitation` summary. Do not
retry create or accept automatically.

An identity can inspect work it received, work it requested, or both. Omit
`direction` on `a2aTasks` for the receiver inbox; `a2aSentTasks` is the
outbound-only alias.

```ts
const directory = await inkbox.a2a.publicDirectory({ q: "research", limit: 25 });
const orgDirectory = await inkbox.a2a.organizationDirectory({ q: "support" });
for (const item of directory.items) {
  console.log(item.card.name, item.cardUrl, item.visibility);
}

await identity.a2aSetPubliclyDiscoverable(true); // admin API key required
await identity.a2aSetAllowPublicEgress(true);

const page = await identity.a2aTasks({
  direction: "both",
  requesterHandle: "coordinator",
  workerHandle: "researcher",
  state: "working",
  contextId: "context-uuid",
  q: "quarterly report",
  since: "2026-07-01T00:00:00Z",
  limit: 25,
});

// Async iterators preserve filters while draining every cursor page.
for await (const message of identity.iterA2AMessages({
  direction: "outbound",
  workerHandle: "researcher",
  role: "agent",
  q: "revenue",
})) {
  console.log(
    message.taskId,
    message.contextId,
    message.taskState,
    message.parts,
  );
}

for (const context of (await identity.a2aContexts({ direction: "both" })).items) {
  console.log(context.name, context.id);
}

await identity.a2aUpdateContext("context-uuid", {
  name: "Quarterly Research Review",
});
```

Task filters: `direction`, `requesterHandle`, `workerHandle`, `state`,
`contextId`, `q`, `since`, `cursor`, `limit`. Message filters additionally
support `taskId` and `role`; `role` is the message author (`caller` or
`agent`), independent of task direction. Message direction defaults to `both`.
Multiple filters are ANDed. Task search returns tasks containing a matching
message; message search returns individual matches with requester/worker and
task/context provenance. Search covers string and numeric content values from
`text` and `data` parts, excludes metadata, and is deterministic newest-first
rather than relevance-ranked.

Use `a2aTask` / `a2aSentTask` for a task's current state and message history.

New contexts start with the persisted name `New A2A Session`. That exact
default may be replaced with a name based on the first task message. Either
participant can rename a context at any time; automatic naming does not replace
a non-default name. Context-level `caller` and `target` remain the
original opener and recipient. Each nested task carries its own authoritative
participants, and tasks in both directions can run concurrently.

The standard client starts a sibling task when `contextId` is supplied without
`taskId`. Supplying `taskId` continues that specific task. This cross-endpoint
reuse is supported between Inkbox identities; external A2A services may define
different behavior.

For a multi-turn worker flow, reply with `intent: "ask_caller"` to request
input; the caller continues the same task through the standard A2A client, and
the worker later replies with `intent: "complete"` or `intent: "fail"`.

Directory methods accept `q`, `cursor`, and `limit`; async iterator variants
follow all pages. Receiver enablement, public egress, and advertised skills may
be changed with the identity's agent-scoped key. Public discoverability and
other admission-policy mutations require an admin API key:
`a2aSetPubliclyDiscoverable`, `a2aSetFilterMode`, `a2aAddContactRule`, `a2aUpdateContactRule`, and
`a2aDeleteContactRule`. Use `a2aResetSkills()` to restore the default Agent
Card skills. Contact-rule directions are `inbound`, `outbound`, or `both`.
Same-organization and public discovery may imply admission. Private
cross-organization calls require requester-outbound and worker-inbound
permission; explicit blocks always win.

## Vault

Encrypted credential vault with client-side Argon2id key derivation and AES-256-GCM encryption. The server never sees plaintext secrets. Requires `hash-wasm` (included as a dependency).

### Initialize

```typescript
// Initialize a new vault (org ID is fetched automatically from the API key)
const result = await inkbox.vault.initialize("my-Vault-key-01!");
console.log(result.vaultId, result.vaultKeyId);
for (const code of result.recoveryCodes) {
  console.log(code); // save these immediately — they cannot be retrieved again
}
```

### Unlock & Read

```typescript
import type { LoginPayload, APIKeyPayload, SSHKeyPayload, OtherPayload } from "@inkbox/sdk";

// Unlock with a vault key — derives key via Argon2id, decrypts all secrets
const unlocked = await inkbox.vault.unlock("my-Vault-key-01!");

// Optionally filter to secrets an agent identity has access to
const unlocked = await inkbox.vault.unlock("my-Vault-key-01!", { identityId: "agent-uuid" });

// All decrypted secrets from the unlock bundle
for (const secret of unlocked.secrets) {
  console.log(secret.name, secret.secretType);
  console.log(secret.payload);   // LoginPayload, APIKeyPayload, SSHKeyPayload, or OtherPayload
}

// Fetch and decrypt a single secret by ID
const secret = await unlocked.getSecret("secret-uuid");
const login = secret.payload as LoginPayload;
console.log(login.username, login.password);
```

### Create & Update

```typescript
// Create a login secret (secretType inferred from payload shape)
await unlocked.createSecret({
  name: "Example dashboard",
  description: "Production IAM user",
  payload: { password: "example-password", username: "admin", url: "https://dashboard.example.com" },
});

// Create an API key secret
await unlocked.createSecret({
  name: "GitHub PAT",
  payload: { apiKey: "ghp_xxx" },
});

// Create an SSH key secret
await unlocked.createSecret({
  name: "Deploy Key",
  payload: { privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----..." },
});

// Create a freeform secret
await unlocked.createSecret({
  name: "Misc",
  payload: { data: "any freeform content" },
});

// Update name/description and/or re-encrypt payload
await unlocked.updateSecret("secret-uuid", { name: "New Name" });
await unlocked.updateSecret("secret-uuid", {
  payload: { password: "new", username: "new" },
});

// Delete
await unlocked.deleteSecret("secret-uuid");
```

### Metadata (no unlock needed)

```typescript
const info    = await inkbox.vault.info();                                  // VaultInfo
const keys    = await inkbox.vault.listKeys();                              // VaultKey[]
const keys    = await inkbox.vault.listKeys({ keyType: "recovery" });       // filter by type
const secrets = await inkbox.vault.listSecrets();                           // VaultSecret[] (metadata only)
const secrets = await inkbox.vault.listSecrets({ secretType: "login" });    // filter by type
await inkbox.vault.deleteSecret("secret-uuid");                             // delete without unlocking
```

### Payload Types

| Type | Interface | Fields |
|------|-----------|--------|
| `login` | `LoginPayload` | `password`, `username?`, `email?`, `url?`, `notes?` |
| `api_key` | `APIKeyPayload` | `apiKey`, `endpoint?`, `notes?` |
| `key_pair` | `KeyPairPayload` | `accessKey`, `secretKey`, `endpoint?`, `notes?` |
| `ssh_key` | `SSHKeyPayload` | `privateKey`, `publicKey?`, `fingerprint?`, `passphrase?`, `notes?` |
| `other` | `OtherPayload` | `data` |

`secretType` is immutable after creation. To change it, delete and recreate.

### Agent Credentials (identity-scoped)

Agent-facing credential access — typed, identity-scoped. The vault stays as the admin surface; `identity.getCredentials()` is the agent runtime surface.

```typescript
import type { Credentials } from "@inkbox/sdk";

// Unlock the vault first (stores state on the client)
await inkbox.vault.unlock("my-Vault-key-01!");

const identity = await inkbox.getIdentity("support-bot");
const creds = await identity.getCredentials();

// Discovery — returns DecryptedVaultSecret[] with name/metadata
const allCreds = creds.list();
const logins   = creds.listLogins();
const apiKeys  = creds.listApiKeys();
const sshKeys  = creds.listSshKeys();
const keyPairs = creds.listKeyPairs();

// Access by UUID — returns typed payload directly
const login   = creds.getLogin("secret-uuid");    // → LoginPayload
const apiKey  = creds.getApiKey("secret-uuid");    // → APIKeyPayload
const sshKey  = creds.getSshKey("secret-uuid");    // → SSHKeyPayload
const keyPair = creds.getKeyPair("secret-uuid");   // → KeyPairPayload

// Generic access — returns DecryptedVaultSecret
const secret = creds.get("secret-uuid");
```

- Requires `inkbox.vault.unlock()` first — throws `InkboxError` if vault is not unlocked
- Results are filtered to secrets the identity has access to (via access rules)
- Cached after first call; call `identity.refresh()` to clear the cache
- `get*` throws `Error` if not found, `TypeError` if wrong secret type

## One-Time Passwords (TOTP)

TOTP secrets are stored inside `LoginPayload.totp` in the encrypted vault. Codes are generated client-side — no server call needed.

### From an agent identity (recommended)

```typescript
import { parseTotpUri } from "@inkbox/sdk";
import type { LoginPayload } from "@inkbox/sdk";

// Create a login with TOTP
const secret = await identity.createSecret({
  name: "GitHub",
  payload: {
    username: "user@example.com",
    password: "s3cret",
    totp: parseTotpUri("otpauth://totp/GitHub:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=GitHub"),
  } satisfies LoginPayload,
});

// Generate TOTP code
const code = await identity.getTotpCode(secret.id);
console.log(code.code);              // e.g. "482901"
console.log(code.secondsRemaining);  // e.g. 17

// Add/replace TOTP on existing login
await identity.setTotp(secretId, "otpauth://totp/...?secret=...");

// Remove TOTP
await identity.removeTotp(secretId);
```

### From the unlocked vault (admin-only)

```typescript
const unlocked = await inkbox.vault.unlock("my-Vault-key-01!");

// Same methods available on UnlockedVault
await unlocked.setTotp(secretId, totpConfigOrUri);
await unlocked.removeTotp(secretId);
const code = await unlocked.getTotpCode(secretId);
```

### TOTPCode fields

| Field | Type | Description |
|---|---|---|
| `code` | `string` | The OTP code (e.g. `"482901"`) |
| `periodStart` | `number` | Unix timestamp when the code became valid |
| `periodEnd` | `number` | Unix timestamp when the code expires |
| `secondsRemaining` | `number` | Seconds until expiry |

## Admin-only Resources

### Mailboxes (`inkbox.mailboxes`)

```typescript
const mailboxes = await inkbox.mailboxes.list();
const mailbox   = await inkbox.mailboxes.get("abc@inkboxmail.com");

// To rename, use `identity.update({ displayName: "New Name" })` —
// the mailbox PATCH endpoint hard-rejects `display_name` with a 422.
// To attach a webhook receiver, see "Webhooks" below.

// DEPRECATED channel path — the mail filter mode now lives on the identity.
// Prefer `identity.update({ mailFilterMode: "whitelist" })` (which does NOT
// return a change notice). This legacy mailbox flip still works and returns one:
const updated = await inkbox.mailboxes.update(mailbox.emailAddress, {
  filterMode: "whitelist",   // or "blacklist" — see FilterMode enum
});
if (updated.filterModeChangeNotice) {
  // Populated when filterMode actually changed.
  const n = updated.filterModeChangeNotice;
  cons

…(truncated)
