--- name: inkbox-ts description: 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. user-invocable: false --- # 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 ├── .getIdentity(handle) → Promise ├── .listIdentities() → Promise ├── .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 └── .createSigningKey() → Promise (DEPRECATED — org-level; use .signingKeys) AgentIdentity (identity-scoped helper) ├── .mailbox → IdentityMailbox | null ├── .phoneNumber → IdentityPhoneNumber | null ├── .mailFilterMode / .phoneFilterMode → FilterMode ├── .getCredentials() → Promise (requires vault unlocked) ├── .listMailContactRules() / .createMailContactRule(...) / .get/.update/.delete ├── .listPhoneContactRules() / .createPhoneContactRule(...) / ... (writes require admin credentials) ├── .getSigningKeyStatus() / .createSigningKey() ├── .listContactCommunicationPolicies() → Promise ├── 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: "

Hi there!

", // 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: "", }, { filename: "chart.png", // inline image: set contentId and reference contentType: "image/png", // it from bodyHtml as . contentBase64: "", // 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!, ""); await inkbox.threads.delete(identity.emailAddress!, ""); } } ``` 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" } // To press keypad keys during a client-driven call, send this JSON // through the call's media WebSocket: {"event": "dtmf", "digits": "1"} // Each event accepts 1-30 keys from 0-9, *, and #, in order. // At most 20 bursts per call can be outstanding, including the one // being sent; further bursts are ignored. // No acknowledgment is sent; wait for the menu's response before another // burst. Do not retry blindly. // 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 identity 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; console.log(n.redundantRuleCount, n.redundantRuleAction, n.newFilterMode); } // Mailbox responses now also carry mailbox.agentIdentityId when linked. // `mailbox.sendingDomain` is the bare domain the mailbox sends from // (platform default or a verified custom domain — see "Custom email domains" below). // Storage (list / get / update all carry these): console.log(mailbox.storageUsedBytes); // bytes stored, e.g. 1288490188 console.log(mailbox.storageLimitBytes); // plan cap, e.g. 2147483648 (2 GiB), or null const usedGib = mailbox.storageUsedBytes / 1024 ** 3; // caps are BINARY — GiB, not GB // Over-cap sends throw StorageLimitExceededError (402) — see "Storage cap (402)". const results = await inkbox.mailboxes.search(mailbox.emailAddress, { q: "invoice", limit: 20 }); // Mailboxes are deleted via the owning identity's cascade — there is no standalone delete: // await identity.delete(); // removes the mailbox + tunnel atomically (cascade) ``` ### Custom email domains (`inkbox.domains`) If your org has registered custom sending domains in the console, list them and (admin-only) set the org default. New mailboxes inherit the org default unless you pass `sendingDomainId` (standalone) or `sendingDomain` (identity). ```typescript import { SendingDomainStatus } from "@inkbox/sdk"; const verified = await inkbox.domains.list({ status: SendingDomainStatus.VERIFIED }); // Admin-scoped API key only — non-admin keys get 403. // Returns the bare new default domain name (or null when reverted to platform). const newDefault = await inkbox.domains.setDefault("mail.acme.com"); // Pass the platform domain (e.g. "inkboxmail.com" in prod) to clear the org default. // Identity create: pick by bare domain name (not id). await inkbox.createIdentity("sales-bot", { sendingDomain: "mail.acme.com" }); // Force the platform default: await inkbox.createIdentity("sales-bot-2", { sendingDomain: null }); // Standalone mailbox creation is gone — provision via createIdentity above. ``` ### Phone Numbers (`inkbox.phoneNumbers`) ```typescript const numbers = await inkbox.phoneNumbers.list(); const number = await inkbox.phoneNumbers.get("phone-number-uuid"); const num = await inkbox.phoneNumbers.provision({ agentHandle: "my-agent", type: "local", state: "NY" }); // local only; toll_free is rejected (422) await inkbox.phoneNumbers.update(num.id, { incomingCallAction: "webhook", // also auto_accept, auto_reject, hosted_agent, or forward incomingCallWebhookUrl: "https://...", }); await inkbox.phoneNumbers.update(num.id, { incomingCallAction: "auto_accept", clientWebsocketUrl: "wss://...", }); await inkbox.phoneNumbers.update(num.id, { incomingCallAction: "hosted_agent", // no URL — Voice AI answers }); await inkbox.phoneNumbers.update(num.id, { incomingCallAction: "forward", forwardingTargetType: "sip", forwardingSipUri: "sip:agent@voice.example.com", }); const hits = await inkbox.phoneNumbers.searchTranscripts(num.id, { q: "refund", party: "remote", limit: 50 }); await inkbox.phoneNumbers.release(num.id); ``` Phone numbers carry the same `filterMode` / `agentIdentityId` / `filterModeChangeNotice` fields as mailboxes; flipping `filterMode` here is the **deprecated** channel path (admin-only; returns a change-notice when the value actually changed). Prefer `identity.update({ phoneFilterMode: "whitelist" })`, which sets the mode on the identity and does not return a change notice. ## Contact Rules All mutation examples in this section require an admin API key. Exact-address allow/block choices override the email or phone mode. Without an exact choice, matching email domain entries apply in their corresponding mode, then the mode's default applies. Identity-level phone rules do not require a dedicated number. Releasing a number preserves permissions. Allow/block lists are scoped to the **agent identity** (mirroring iMessage), addressed by `agentHandle`. The identity's `mailFilterMode` / `phoneFilterMode` decides whether each channel's rules act as a whitelist or blacklist. Mail matches by exact email or domain; phone matches by exact E.164 number. Returned rows are `MailIdentityContactRule` / `PhoneIdentityContactRule`, keyed by `rule.agentIdentityId` (not a mailbox/phone-number id). ```typescript import { MailRuleAction, MailRuleMatchType, PhoneRuleAction, PhoneRuleMatchType, DuplicateContactRuleError, } from "@inkbox/sdk"; const identity = await inkbox.getIdentity("sales-agent"); // Mail rules via the identity convenience methods. const rule = await identity.createMailContactRule({ action: MailRuleAction.ALLOW, // or BLOCK matchType: MailRuleMatchType.DOMAIN, // or EXACT_EMAIL matchTarget: "example.com", }); await identity.listMailContactRules(); await identity.getMailContactRule(rule.id); await identity.updateMailContactRule(rule.id, { action: MailRuleAction.ALLOW, }); // admin-only await identity.deleteMailContactRule(rule.id); // admin-only // Phone rules — same shape, only matchType: "exact_number" is supported. // Phone helpers require the identity to have a phone number (else InkboxError). await identity.createPhoneContactRule({ action: PhoneRuleAction.BLOCK, matchTarget: "+15551234567", matchType: PhoneRuleMatchType.EXACT_NUMBER, }); await identity.listPhoneContactRules(); // Equivalent org-level resources, keyed by agentHandle, with an org-wide listAll: await inkbox.mailIdentityContactRules.create("sales-agent", { action: MailRuleAction.ALLOW, matchType: MailRuleMatchType.DOMAIN, matchTarget: "example.com", }); await inkbox.mailIdentityContactRules.list("sales-agent"); await inkbox.mailIdentityContactRules.listAll({ agentIdentityId: identity.id }); // admin-only, org-wide await inkbox.phoneIdentityContactRules.listAll(); // admin-only, org-wide // Duplicate (matchType, matchTarget) on the same identity throws 409: try { await identity.createMailContactRule({ action: MailRuleAction.ALLOW, matchType: MailRuleMatchType.DOMAIN, matchTarget: "example.com", }); } catch (e) { if (e instanceof DuplicateContactRuleError) { console.log(e.existingRuleId); // id of the rule that already matched } } ``` ### Filter mode The whitelist/blacklist mode lives on the identity. Flip it with `identity.update` (admin-only). Unlike the deprecated channel update, this does **not** return a `FilterModeChangeNotice`. `phoneFilterMode` requires the identity to have a phone number (else a 422). ```typescript await identity.update({ mailFilterMode: "whitelist", phoneFilterMode: "blacklist" }); console.log(identity.mailFilterMode, identity.phoneFilterMode); ``` ### Deprecated: per-mailbox / per-number rules The legacy per-mailbox `inkbox.mailContactRules` and per-number `inkbox.phoneContactRules` resources still work but hit deprecated server routes (Sunset 2026-08-31). Prefer the identity-keyed surface above. ```typescript import { MailRuleAction, MailRuleMatchType, PhoneRuleAction, PhoneRuleMatchType, } from "@inkbox/sdk"; // Deprecated — per-mailbox mail rule: await inkbox.mailContactRules.create(mailbox.emailAddress, { action: MailRuleAction.ALLOW, matchType: MailRuleMatchType.DOMAIN, matchTarget: "example.com", }); await inkbox.mailContactRules.listAll({ mailboxId: mailbox.id }); // Deprecated — per-number phone rule: await inkbox.phoneContactRules.create(num.id, { action: PhoneRuleAction.BLOCK, matchType: PhoneRuleMatchType.EXACT_NUMBER, matchTarget: "+15551234567", }); ``` ## Contacts Shared address book with whole-group email/phone visibility and separate communication choices for each address. Phone covers SMS, calls, and iMessage. Profile and Memories do not grant communication access. Existing-contact identifier changes and suggestion absorption require admin credentials. With admin credentials, `inkbox.contacts.access.get(handle, contactId)` returns `email`/`phone` objects with `visible` and `contactable`, plus `profile` and `memories`. `.update(handle, contactId, { email: { visible: true, contactable: [] } })` gives View-only email access. A nonempty contactable list allows those current addresses and blocks the rest. Omitted fields are preserved; `profile: false` hides omitted or empty groups and omitted Memories, while explicit choices win. Visibility alone does not grant communication. The roster's optional `access` field has the same effective shape. The legacy `.access.list(contactId)` remains read-only metadata. For atomic group-access creation, pass `permissions: { identityId, email: { visible: true, contactable: ["ada@example.com"] } }`, with the matching contact email. Use group objects or the older boolean maps, not both. Use `inkbox.contacts.permissions.get(handle, contactId)` with admin credentials to read effective `emails` and `phones` boolean maps plus `profile` and `memories` booleans. Call `.update(handle, contactId, { emails: { "ada@example.com": true }, profile: true, memories: false })` to save explicit choices. Omitted fields and addresses stay unchanged; no revision is required. For atomic creation, pass `permissions: { identityId, emails: { "ada@example.com": true }, profile: true, memories: false }` to `inkbox.contacts.create`, along with the matching contact email. All initial choices are saved with the contact. Advanced policies remain under `inkbox.contacts.communicationPolicy`: `.get(contactId, identityId)` returns `addresses`, `effectiveVisibility`, `visibility`, and `revision`. `.replace(contactId, { expectedRevision, identityId, addresses: [{ kind: "email", value: "ada@example.com", action: "allow", expectedAction: "inherit" }] })` makes guarded edits; omitted `visibility` is preserved. `.preview(contactId, identityId)` and `.listForIdentity(handle)` return filtered saved views. Agent keys can list only their own view. With admin credentials, `.listManagementForIdentity(handle, { q: "Jane", order: "name", limit: 20 })` includes hidden contacts and returns compact contact summaries, visibility settings, and effective access. Email/Phone results are `all`, `some`, `none`, or `no_identifiers`; Profile/Memories are booleans. Identity-owned communication rules have optional nullable `rule.contact` cards, filtered for the caller and without memories. Merging requires an admin-scoped API key. Active memories have per-kind and contact-wide limits. Delete a fact from each kind named by a merge error, or any active fact when it names `total`, then retry. Untyped memories count toward the total. ```typescript import type { CreateContactOptions, ContactEmail, ContactPhone } from "@inkbox/sdk"; // CRUD const contact = await inkbox.contacts.create({ givenName: "Ada", familyName: "Lovelace", emails: [{ label: "work", value: "ada@example.com" }], phones: [{ label: "mobile", value: "+15551234567" }], }); await inkbox.contacts.get(contact.id); await inkbox.contacts.list({ q: "ada", order: "recent", reviewStatus: ["confirmed"] }); await inkbox.contacts.update(contact.id, { jobTitle: "Analyst" }); await inkbox.contacts.delete(contact.id); await inkbox.contacts.bulkDelete(["contact-uuid-1", "contact-uuid-2"]); // Reverse-lookup — exactly one filter required (else thrown before HTTP) await inkbox.contacts.lookup({ email: "ada@example.com" }); await inkbox.contacts.lookup({ emailDomain: "example.com" }); await inkbox.contacts.lookup({ phone: "+15551234567" }); await inkbox.contacts.lookup({ emailContains: "ada" }); await inkbox.contacts.lookup({ phoneContains: "555" }); // Compatibility access information is read-only await inkbox.contacts.access.list(contact.id); // Facts, citations, correspondence, and duplicate merging // fact.kind is "profile", "preference", or "context"; unlocked extracted // context facts drop out of list() at fact.expiresAt. Locked facts stay active. const facts = await inkbox.contacts.facts.list(contact.id); await inkbox.contacts.facts.list(contact.id, { includeExpired: true }); const sourceUrl = facts[0]?.citations[0]?.sourceUrl; if (sourceUrl) await inkbox.contacts.facts.resolveCitationUrl(sourceUrl); // Hand-written facts never expire; create/update/delete are admin only. // Any update makes a fact user-authored, clears expiry, and revives it. Content // changes also remove confidence and citations; kind-only changes preserve them. const fact = await inkbox.contacts.facts.create(contact.id, { content: "Prefers email over calls", kind: "preference", }); await inkbox.contacts.facts.update(contact.id, fact.id, { kind: "profile" }); if (facts[0]) await inkbox.contacts.facts.delete(contact.id, facts[0].id); // admin only const history = await inkbox.contacts.correspondence.get(contact.id, { identityId: "identity-uuid", channels: ["email", "sms"], }); const survivor = await inkbox.contacts.merge(contact.id, { losingContactIds: ["duplicate-contact-uuid"], }); // vCards const result = await inkbox.contacts.vcards.import(vcfText); // bulk, ≤5 MiB, ≤1000 cards console.log(result.createdIds); for (const item of result.errors) { console.log(item.index, item.error); } for (const item of result.conflicts) { console.log(item.index, item.conflictingContactId); } const vcf = await inkbox.contacts.vcards.export(contact.id); // vCard 4.0 string const batch = await inkbox.contacts.vcards.exportMany(["contact-uuid-1", "contact-uuid-2"]); console.log(batch.vcard); ``` `contacts.create` saves a matching suggested contact instead of failing: when an email or phone in the request already belongs to an unreviewed contact, that contact is confirmed and returned with its memories and existing identifiers. Name fields are replaced; omitted non-name profile fields are preserved, and supplied non-name fields are applied. This also works with agent-scoped API keys. When the address belongs to a saved contact, to more than one contact, or is in conflict, the call still fails with HTTP 409 `duplicate_contact_identifier`. ## Notes Admin-only free-form notes with per-identity access grants. There is no wildcard for notes — grant identities explicitly. ```typescript const note = await inkbox.notes.create({ body: "Customer prefers email follow-up.", title: "Ada" }); await inkbox.notes.get(note.id); await inkbox.notes.list({ q: "email", identityId: "agent-uuid", order: "recent", limit: 50 }); await inkbox.notes.update(note.id, { body: "Updated body" }); await inkbox.notes.update(note.id, { title: null }); // clear title; body cannot be null await inkbox.notes.delete(note.id); // Access grants (admin + JWT only) await inkbox.notes.access.list(note.id); await inkbox.notes.access.grant(note.id, "agent-uuid"); await inkbox.notes.access.revoke(note.id, "agent-uuid"); ``` ## Whoami ```typescript // Check the authenticated caller's identity const info = await inkbox.whoami(); console.log(info.authType); // "api_key" or "jwt" console.log(info.organizationId); if (info.authType === "api_key") { console.log(info.keyId, info.label); } ``` Returns `WhoamiApiKeyResponse` (with `keyId`, `label`, `creatorType`, `authSubtype`, etc.) or `WhoamiJwtResponse` (with `email`, `orgRole`, etc.) — discriminated on `authType`. For branching on API-key scope, compare against the exported constants: ```typescript import { AUTH_SUBTYPE_API_KEY_ADMIN_SCOPED, AUTH_SUBTYPE_API_KEY_AGENT_SCOPED_CLAIMED, AUTH_SUBTYPE_API_KEY_AGENT_SCOPED_UNCLAIMED, } from "@inkbox/sdk"; if (info.authType === "api_key" && info.authSubtype === AUTH_SUBTYPE_API_KEY_ADMIN_SCOPED) { // admin-only operations (filter_mode flips, rule updates/deletes, etc.) } ``` ## Tunnels Bring a local Node process online at a public `https://{name}.inkboxwire.com` URL. Outbound HTTP/2 only — no inbound port to open. POSIX only; the data-plane runtime lives on a separate package subpath so the main `@inkbox/sdk` entry stays browser-safe. ```typescript import { connect } from "@inkbox/sdk/tunnels/connect"; // Forward to a local URL (edge mode — Inkbox terminates TLS at the edge) const listener = await connect(inkbox, { name: "my-app", forwardTo: "http://127.0.0.1:8080", }); console.log(listener.publicUrl); // https://my-app.inkboxwire.com console.log(listener.status, listener.isConnected, listener.lastConnectedAt); await listener.wait(); // until SIGINT/SIGTERM // In-process Fetch-API HTTP handler import type { InkboxHandler } from "@inkbox/sdk/tunnels/connect"; const handler: InkboxHandler = async (req, ctx) => { return new Response("hi", { headers: { "content-type": "text/plain" } }); }; await connect(inkbox, { name: "my-app", handler }); // In-process WebSocket handler (HTTP path still required) import type { InkboxWsHandler } from "@inkbox/sdk/tunnels/connect"; const wsHandler: InkboxWsHandler = async (ws) => { await ws.accept(); for await (const msg of ws) { await ws.send(typeof msg === "string" ? `echo: ${msg}` : msg); } }; await connect(inkbox, { name: "my-app", handler, wsHandler }); // Passthrough TLS (SDK terminates; cert auto-signed via the control plane) // Set tls_mode when you create the identity — it's fixed at create time. await inkbox.createIdentity("my-app", { tunnel: { tlsMode: "passthrough" } }); await connect(inkbox, { name: "my-app", forwardTo: "http://127.0.0.1:8080", }); ``` Tunnels are provisioned atomically by `inkbox.createIdentity(...)`; there is no standalone `create` / `delete` / `restore` / `rotateSecret` surface. `listener.status` is `idle`, `connecting`, `connected`, `reconnecting`, `closed`, or `superseded`. `listener.isConnected` is local runtime liveness; `listener.lastConnectedAt` remains set while reconnecting. `listener.tunnel` remains the bootstrap resource snapshot. `serveForever()` and `wait()` both reject on terminal failures, so attach a rejection handler to a fire-and-forget `serveForever()` call. In-process WebSocket handlers receive `WsServerDraining` (`4500`) for planned drain and `WsConnectionLost` (`1011`) for cold connection loss. Both extend `WsClosed` and advise reconnection. Reads + edit: ```typescript await inkbox.tunnels.list(); await inkbox.tunnels.get("tunnel-uuid"); await inkbox.tunnels.update("tunnel-uuid", { metadata: { team: "gtm" }, }); // Passthrough only: await inkbox.tunnels.signCsr("tunnel-uuid", { csrPem }); ``` Data-plane auth uses the same `apiKey` the `Inkbox` client was constructed with — admin-scoped or identity-scoped (matching the tunnel's identity). Mint a per-agent identity-scoped key via `inkbox.apiKeys.create({ scopedIdentityId })`. Selected `connect()` options: `poolSize` (1–32), `stateDir` (default `~/.inkbox/tunnels/{name}`), `onStatus` callback, `allowRemoteForwarding: false` (loopback-only allowlist), `forwardToVerifyTls: true`, `forwardToCaBundle`. In passthrough mode the state dir holds the per-tunnel private key — treat it like an SSH key dir. For full options, lifecycle notes, and Python examples, see `skills/inkbox-tunnels/SKILL.md`. ## Webhooks & Signature Verification **Availability:** Mixed-event identity subscriptions and explicit identity-wide list scope require SDK/CLI **0.7.8 or later** and `GET /webhooks/catalog` returning `supports_identity_subscriptions: true`. Until then, use separate mail, text, and identity-event subscriptions with their existing mailbox, phone, and identity selectors; omit the scope option. The examples in this section that combine families or create mail/text subscriptions by identity assume that capability is available. Explicit identity scope fails clearly when the capability is missing; it does not fall back to a partial list. Webhooks are configured directly on the mailbox or phone number — no separate registration. ```typescript import { verifyWebhook, MailWebhookPayload, TextWebhookPayload, PhoneIncomingCallWebhookPayload, } from "@inkbox/sdk"; // Each agent identity has its own webhook signing key. Create/rotate it // (plaintext returned once — save it), or read its status: const key = await identity.createSigningKey(); // → SigningKey const status = await identity.getSigningKeyStatus(); // → SigningKeyStatus { configured, createdAt } // Org-level resource, keyed by agentHandle: const key2 = await inkbox.signingKeys.createOrRotate("sales-agent"); const status2 = await inkbox.signingKeys.getStatus("sales-agent"); // DEPRECATED: org-level inkbox.createSigningKey() — with an agent-scoped key it // still rotates that identity's key; with an admin key the server returns 409. // Verify, then parse + discriminate const valid = verifyWebhook({ payload: req.body, // Buffer or string headers: req.headers as Record, secret: "whsec_...", }); if (!valid) return res.status(403).end(); const payload = JSON.parse(req.body.toString()) as TextWebhookPayload; if (payload.event_type === "text.delivery_failed") { console.error(payload.data.text_message.error_code, payload.data.text_message.error_detail); } ``` Headers checked: `x-inkbox-signature`, `x-inkbox-request-id`, `x-inkbox-timestamp`. Algorithm: HMAC-SHA256 over `"{requestId}.{timestamp}.{body}"`. **Event taxonomy:** Use `inkbox.webhooks.subscriptions.list({ agentIdentityId: identity.id, scope: "identity" })` to list every notification family, including mixed subscriptions. Omitting scope preserves the legacy single-family views. - **Mail** (envelope, fire-and-forget) — `message.received`, `message.sent`, `message.forwarded`, `message.delivered`, `message.bounced`, `message.failed`. Subscribe via `inkbox.webhooks.subscriptions.create({ agentIdentityId, url, eventTypes })`. On `message.received`, `data.message` includes the plain-text `body` (whole under a size cap, else a prefix with `body_truncated: true` / `body_state: "truncated"`); when truncated, hydrate with `inkbox.messages.get(message.email_address, message.id)` — use `id` (row id), not `message_id` (RFC 5322 header). Present-with-`null` on the other events, absent on pre-feature payloads. - **Text** (envelope, fire-and-forget) — `text.received`, `text.sent`, `text.delivered`, `text.delivery_failed`, `text.delivery_unconfirmed`. Subscribe via `inkbox.webhooks.subscriptions.create({ agentIdentityId, url, eventTypes })`. The text-message body carries `delivery_status` as an outbound message-level rollup; 1:1 traffic also hoists `error_code`, `error_detail`, `sent_at`, `delivered_at`, and `failed_at`. On group outbound those legacy detail fields are `null` and per-recipient state lives in `recipients[]`. - **iMessage** (envelope, fire-and-forget) — `imessage.received`, `imessage.reaction_received`, plus the outbound delivery lifecycle `imessage.sent`, `imessage.delivered`, `imessage.delivery_failed` (declined/error; details on the message object). Subscribe via `inkbox.webhooks.subscriptions.create({ agentIdentityId, url, eventTypes })` — owned by the **agent identity**, since shared iMessage pool numbers are not org resources. `data.message` is populated on `imessage.received` and the three delivery-lifecycle events; `data.reaction` on `imessage.reaction_received`. Fan-out only happens while the identity is active and `imessageEnabled`; contact-rule-blocked traffic is never delivered. - **Call lifecycle** (envelope, fire-and-forget + replayable) — `call.ended`, owned by the **agent identity** (like iMessage). Subscribe via `inkbox.webhooks.subscriptions.create({ agentIdentityId, url, eventTypes: ["call.ended"] })`. `CallEndedWebhookPayload.data` carries the `call` (`WebhookPhoneCall`, with derived `duration_seconds`), resolved `contacts` / `agent_identities`, an always-present `transcript_url` (authoritative verbatim, fetch with an admin API key), and an inline `transcript` block (`WebhookCallTranscript`, middle-cut/abridged) present when the platform captured a transcript for the call, otherwise `null` — discriminate a turn from the abridgment marker on `"marker" in entry`. Voice AI call fields (all optional so pre-Voice AI payloads parse): `data.call` carries `mode` / `reason`; `data` carries `outcome` (`"completed" | "no_answer" | "declined" | "failed"`, `null` iff `mode` is `client_websocket`) and `post_call_action_items` (open items only, seq-ascending, mirroring `PhoneCall.postCallActionItems`). Voice AI calls fire `call.ended` on **every** terminal state (including never-connected ones like `no_answer`), not just connected calls. These events can share one subscription with mail, text and A2A notifications. - **Inbound call** (flat, synchronous) — `PhoneIncomingCallWebhookPayload` on a phone number's `incomingCallWebhookUrl`. Not subscribable; the URL stays on the phone-number resource because the response (`action: "answer" | "reject"` + optional `clientWebsocketUrl`) decides the call's fate. Non-200, invalid bodies, and timeouts are treated as "decline routing" by Inkbox. (Contrast `call.ended` above, which is the replayable post-call fan-out.) **Subscription resource:** `inkbox.webhooks.subscriptions.{list,get,create,update,delete}`. One identity owns each subscription, and its nonempty distinct event selection may combine all notification families even without configured channels. Multiple destinations are supported; overlapping events at the same identity and URL conflict. Use the identity selector; legacy mailbox/phone selectors remain mutually exclusive and resolve to the owning identity. PATCH replaces the full event list. The API validates exact catalog names; the SDK validates event prefixes and excludes `phone.incoming_call`. `create(...)` returns a `WebhookSubscriptionCreateResponse`. The **first** subscription created for an identity that has no signing key yet carries that identity's `signingKey` **once** (otherwise `null`) — capture it then, it cannot be retrieved again. Every subscription (read or created) also carries `ownerIdentityId`, the resolved owning agent identity. ```typescript const created = await inkbox.webhooks.subscriptions.create({ mailboxId: mailbox.id, url: "https://example.com/hook", eventTypes: ["message.received"], }); console.log(created.ownerIdentityId); if (created.signingKey) saveSecret(created.signingKey); // populated once if the identity had no key yet ``` **Conversation context:** opt a mail, text, or iMessage subscription into per-class history on **received** events (`message.received`, `text.received`, `imessage.received`) with `contextConfig` — `email` / `texts` / `calls`, each `{ mode: "count", count: N }` (1..50) or `{ mode: "window", hours: H }` (1..168). Other event types, including A2A, ignore this configuration. On `update` it is tri-state: omit = unchanged, `null` = clear, object = replace. Received-event payloads then carry an optional `payload.data.context` keyed by class; optional fields are absent, not `null`, so guard with `?.`. A skipped class ships `items: []` plus a `skipped` reason; call transcript entries are turns or an abridgment marker, discriminated on `"marker" in entry`. Config types `WebhookContextConfig` / `WebhookContextClassConfig` and payload types `WebhookContext` / `WebhookContextBlock` / `WebhookTranscriptEntry` (and the item types) are exported from `@inkbox/sdk`. ```typescript await inkbox.webhooks.subscriptions.create({ mailboxId: mailbox.id, url: "https://example.com/hook", eventTypes: ["message.received"], contextConfig: { email: { mode: "count", count: 10 } }, }); await inkbox.webhooks.subscriptions.update(created.id, { scope: "identity", contextConfig: null }); // clear ``` **Delivery auth token:** for endpoints that require their own `Authorization` header, pass `authToken` on `create` / `update` — every delivery (and replay) then carries `Authorization: Bearer ` alongside the signature headers. Reads return the stored token as `authToken` (`null` when unset) plus the boolean `hasAuthToken` flag; both default to unset on servers that predate the fields. On `update` it is tri-state: omit = unchanged, `null` = clear, string = replace. **Mail contact / identity resolution:** `data.contacts` and `data.agent_identities` are lists of `{ bucket, address, id, ... }` entries (always present, possibly empty). Inbound events resolve `from` + every `cc`; outbound events resolve every `to` + `cc` + `bcc`. Pair entries to the source field by `(bucket, address)`. Contact entries carry active memory text newest-first in `memories`; use `match.memories ?? []` for older replays. Outbound payloads also carry `data.message.bcc_addresses` (`null` on inbound, since BCC is not visible to recipients). **Phone/text contact / identity resolution:** `data.contacts` (text) and top-level `contacts` (inbound call) are lists of `{ id, name, memories }` matches; `memories` contains active memory text newest-first. `data.agent_identities` mirrors contacts for matched agent identities but does not carry memories. Scoped to the identity that owns the receiving phone number; both default to `[]` when nothing matches. Group text events carry per-recipient delivery rows in `data.text_message.recipients`; **outbound group lifecycle** events name the event target in `data.recipient_phone_number` (one webhook per recipient leg). Inbound and outbound 1:1 events leave `data.recipient_phone_number` as `null` — the singular peer is already in `data.text_message.remote_phone_number` (inbound) or `data.text_message.recipients[0]` (outbound 1:1). Exported wire types: `MailWebhookPayload`, `TextWebhookPayload`, `IMessageWebhookPayload`, `PhoneIncomingCallWebhookPayload`, `WebhookContact`, `WebhookAgentIdentity`, `WebhookMailContact`, `WebhookMailAgentIdentity`, `RawTextMessageRecipient`, the conversation-context shapes (`WebhookContext`, `WebhookContextBlock`, `WebhookTranscriptEntry`, and item types), plus event-type string unions (`MailWebhookEventType`, `TextWebhookEventType`, `IMessageWebhookEventType`) and wire enums (`MessageStatus`, `CallStatusWire`, `HangupReasonWire`, `SmsDeliveryStatusWire`, etc.). All fields are snake_case to match the raw JSON body. ## Error Handling ```typescript import { InkboxAPIError, DuplicateContactRuleError, RedundantContactAccessGrantError, StorageLimitExceededError, } from "@inkbox/sdk"; try { const identity = await inkbox.getIdentity("unknown"); } catch (e) { if (e instanceof InkboxAPIError) { console.log(e.statusCode); // HTTP status (e.g. 404) console.log(e.detail); // string for legacy errors, object for structured ones console.log(e.agentSupport); // Support Agent instructions, or null } } ``` `InkboxAPIError.detail` is typed as `InkboxAPIErrorDetail` — either a string or a structured object. Catch the narrower subclasses when you need the parsed fields: - `DuplicateContactRuleError` — 409 when creating a contact rule with an already-taken `(matchType, matchTarget)` on the same resource. Exposes `.existingRuleId: string`. - `RedundantContactAccessGrantError` — 409 when an identity-viewer grant is redundant (e.g. a specific viewer on top of an active wildcard). Exposes `.error` and `.detailMessage`. - `StorageLimitExceededError` — 402 when a send / reply-all / forward would push the mailbox past its plan storage cap. Exposes `.message` (also as `.detailMessage`), `.upgradeUrl`, and `.limitBytes`. Delete messages or threads to free space (immediate), or upgrade. A `402` whose `detail` is a plain string stays a plain `InkboxAPIError`. ## Key Conventions - All method and property names are **camelCase** - `iterEmails()` / `iterUnreadEmails()` return `AsyncGenerator` — use `for await...of` - `listCalls()` returns `Promise` — offset pagination, not a generator - To clear a nullable field (e.g. webhook URL), pass `field: null` - No context manager needed — `new Inkbox({...})` is all that's required - All methods are `async` and return Promises — always `await` them Use `subscriptions.update(sub.id, { scope: "identity", eventTypes })` to replace the selected events and `subscriptions.delete(sub.id, { scope: "identity" })` to remove the subscription. Delivery rows expose `replayable` and `replayUnavailableReason` while retaining their original `webhookSubscriptionId`. ## Custom email signatures Custom signatures are saved per mailbox and require an eligible paid plan to set or enable. Each HTML/text field supports up to 16,384 characters. HTML is sanitized; logos must use absolute HTTPS URLs. Updating HTML without text generates a plain-text fallback. Omitted fields stay unchanged; null clears saved content. If saved text is null, sending derives it from HTML when possible. Disable without deleting to pause automatic insertion. Disabling and clearing remain available on every plan. Signatures are inserted when mail is sent, including replies, forwards, and sent drafts; do not append them manually. The Inkbox watermark is controlled separately. Signed/encrypted SMTP mail cannot have a custom signature inserted; disable automatic insertion before sending those messages. A `.sig` file is not a standardized attachment format: read its UTF-8 text or HTML content into the corresponding field; images and proprietary formats are not imported. ```typescript await inkbox.mailboxes.update("alex@example.com", { signatureHtml: 'Alex
Website', signatureEnabled: true, }); // Pause without deleting; use null for both content fields to clear them. await inkbox.mailboxes.update("alex@example.com", { signatureEnabled: false }); ``` ## Verified domains An organization admin can prove DNS control and attach a domain to an agent. The domain follows the agent's visibility: public agents show it publicly; private agents show it to their organization and authorized A2A peers. Proof expires at the returned `valid_until`; assertions do not establish legal identity or endorse an agent. Keep the TXT record in place. Domain certification is separate from custom email sending domains. See [verified domains](https://inkbox.ai/docs/capabilities/verified-domains) for expiry, ownership, and recovery rules. These methods require version 0.7.12 or later. ```typescript import { Inkbox } from "@inkbox/sdk"; const client = new Inkbox(); let claim = await client.organizationDomains.create("example.com"); console.log(claim.dnsRecord.name, claim.dnsRecord.value); // Add the exact TXT record before verifying. claim = await client.organizationDomains.verify(claim.id); if (claim.state === "verified") { await client.identities.setDomainAffiliation("helper", { domainClaimId: claim.id, }); } for await (const item of client.a2a.iterPublicDirectory({ q: "example.com" })) { console.log(item.card.name); } ``` Claim methods: `create`, `list`, `get`, `verify`, `delete`. Use `client.identities.getDomainAffiliation(handle)` for saved settings, `setDomainAffiliation(handle, { domainClaimId })` to attach a domain, and `removeDomainAffiliation(handle)` to remove it. A2A participants and messages expose optional `.affiliation`. Webhook types retain wire casing. Directory `q` searches handles, descriptions, skills, and published verified domains, including domain fragments. Text matches can include unverified agents. Use `verifiedDomain: "example.com"` to require an exact current domain in public results. Preserve the query and filters when following a cursor. ## Message retries Send methods generate a key and preserve it during bounded request retries. Application workflows spanning calls can supply `idempotencyKey` and reuse the exact input. A replay returns the original response, while normal message reads show current delivery status. Request retries do not guarantee delivery retries. Recover a failed send's key with the public `getMessageRequestKey(error)` helper. Use `inkbox.messageSends.lookup(...)` or `lookupEmail(...)` to recover a lost ID. An unavailable result does not mean the original message was never sent. ## Slack See the [Slack API and onboarding guide](https://github.com/inkbox-ai/inkbox/blob/main/sdk/typescript/README.md#slack) for implemented SDK/CLI methods. Use an existing identity. Select a workspace explicitly for live reads and mutations. Organization-member sessions, organization admin API keys, and claimed agent keys can save and list setup workspaces in their organization. Claimed agent keys can prepare and install only their own identity’s app; organization credentials can select an identity in their organization. Installation availability does not imply preparation is ready. Save both app-configuration tokens for the target workspace, then prepare the identity’s app using that saved provisioning-workspace UUID. Use `save_provisioning_workspace` / `saveProvisioningWorkspace` or CLI `slack provisioning-workspace save --credentials-file ` (use `-` for stdin). Reuse safe metadata from `list_provisioning_workspaces` / `listProvisioningWorkspaces`. Tokens are write-only; never put them in command arguments or output. Pass the saved ID to `start_setup` (Python/Rust), `startSetup` (TypeScript), or `slack setup start --provisioning-workspace-id `. Poll connection reads until `setup.status` is `ready`, with a bounded wait and a few seconds between reads. For `needs_credentials`, update workspace credentials first. Do not blindly retry an unknown setup outcome. Start installation only when ready. Open the returned URL in a browser; treat the full URL as a secret. After approval, list connections again to confirm the expected workspace is connected. The app is bound to its chosen workspace; no client-invitation workflow is supported. Join accessible public channels or invite the agent to selected private channels. Slack Connect is supported when the selected connection has access. Use explicit connection IDs and stable caller-provided idempotency keys for sends and utility mutations (reactions, pins, own-message edits/deletions, join/leave, uploads, and native processing status). Poll sends only while sending and operations only while in_progress. Unknown is terminal uncertainty and must not be blindly repeated. Recover lost send responses with `get_action_by_key` / `getActionByKey` or CLI `slack action get-by-key --connection-id --idempotency-key `. A 404 does not prove no send occurred; never use missing lookup data to justify a new key. Fresh failed rate-limited sends may include `retry_after` / `retryAfter` seconds; honor that delay before a deliberate new attempt. Stored action reads and same-key replays do not retain this hint. A recorded terminal action is never resent by replaying its key. Send and utility keys use independent per-connection namespaces; utilities emit no outcome webhook, so read their status through operation lookup. Inspect capabilities for missing scopes; native processing support remains workspace-dependent. General file uploads accept standard base64 for 1 byte..10 MiB (CLI: a local --file). Retained history is separate from bounded live reads and webhook diagnostics. Capture is automatic for observed messages in accessible conversations, with no time-based retention limit. Organization-member sessions and organization admin API keys can disconnect connections, set retention, or purge retained history; claimed agent keys cannot. Purging history does not stop capture. Omitted retention resets to no time limit. For search, start with `client.slack.searchMessages({ q: "release notes" })` across the identity's workspace connections. Do not loop over connections or require a connection ID for a general search. Agent credentials infer the identity; other credentials require an explicit identity. Use a connection filter only to narrow the search. Results include connection IDs. Use plain English keywords, ranked by relevance then recency; not Slack query operators or semantic search. Attachment bodies are not indexed. Follow the returned cursor with the same filters even for short or empty pages, until no cursor remains. Search errors are not evidence of no matches. Use archive listing, bounded backfill/restart, and coverage; do not infer complete workspace/thread history from one page or a completed channel import. Purge deletes retained history without stopping new capture. Archive reads require current connection/conversation access. Slack webhooks select incoming messages with `slack.dm_received`, `slack.group_dm_received`, `slack.channel_message_received`, `slack.mention_received`, and `slack.thread_reply_received`. Overlapping selections produce one logical delivery per subscription, choosing the first selected match in mention, thread, DM, group DM, channel priority. Subscriptions cover all accessible conversations across connected workspaces. There are no Slack-specific filters. Context applies only to received mail, text, and iMessage events; Slack historical delivery replay is unsupported. The runtime owns attention rules, watched threads, and its own memory. Webhook delivery order is not guaranteed. ## Threaded iMessage replies Requires SDK/CLI **0.7.13 or later**. ```typescript const message = await identity.getIMessage(messageId); const reply = await identity.sendIMessage({ conversationId: message.conversationId, replyToMessageId: message.id, text: "Agreed — let's use that option.", }); const page = await identity.getIMessageThread(message.id, { limit: 50 }); if (page.nextCursor) { const nextPage = await identity.getIMessageThread(message.id, { cursor: page.nextCursor }); } if (message.threadId) { const thread = await identity.getIMessageConversationThread(message.conversationId, message.threadId); const rows = await identity.listIMessages({ conversationId: message.conversationId, threadId: message.threadId }); } ``` `IMessage` exposes optional-nullable `replyToMessageId`, `threadId`, and `threadRootMessageId`; webhook message fields use their snake_case equivalents. Resource-level equivalents are `imessages.get`, `getThread`, `getConversationThread`, `send`, and `list`; these also accept `agentIdentityId`. Thread IDs are opaque and distinct from message IDs. Thread pages include the root and its replies in chronological order; follow `next_cursor` (Python/Rust) or `nextCursor` (TypeScript/CLI) until null. A standalone message can have its own thread ID and use its own message ID as the root, even before anyone replies. A non-null `reply_to_message_id` / `replyToMessageId` identifies a visible reply parent; a thread ID alone does not mean the message has replies. Thread metadata may be null for pending or older messages, and the root or direct parent can be unavailable. Conversation message lists remain flat and newest-first. A thread filter requires its conversation ID; it uses the existing limit/offset pagination, unlike the chronological thread endpoints. Native replies work in supported one-to-one and group iMessage conversations. Use a message from the same conversation. With plain fallback enabled (the default), the API sends an ordinary message in that conversation when native threading is unsupported: the target is known to use SMS/RCS or was downgraded. This does not force a particular transport. An ordinary fallback has no reply parent and does not join the target's native thread. Invalid or inaccessible targets, unsettled messages, and missing reply metadata still fail. Delivery errors are not retried as new ordinary messages. If a previously supported target can no longer receive a native reply, that send may fail even with fallback enabled. Read the message status and error fields after queueing; delivery webhooks do not cover every failure before dispatch. Omitting the target preserves ordinary sending. Set `plainReplyFallback: false` on `sendIMessage` or `imessages.send` to require a native reply instead.