npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@inkbox/sdk

v0.7.7

Published

TypeScript SDK for the Inkbox API

Readme

@inkbox/sdk

TypeScript SDK for the Inkbox API — API-first communication infrastructure for AI agents (email, phone, identities, encrypted vault — login credentials, API keys, key pairs, SSH keys, OTP, etc.).

Install

See Companion mode for complete group-conversation initialization.

npm install @inkbox/sdk

Requires Node.js ≥ 22.

Note on Workers/Deno/browsers. The control-plane CRUD surface (inkbox.tunnels.list/get/create/... etc.) is portable to Workers, Deno, and browsers — it only depends on the global fetch. The data-plane runtime exposed via import { connect } from "@inkbox/sdk/tunnels/connect" requires node:http2, node:tls, and node:net, so that subpath is Node-only. Use the Python SDK (inkbox.tunnels.connect()) if you need to run the data plane on a non-Node runtime.

Authentication

You'll need an API key to use this SDK. Get one at inkbox.ai/console.

new Inkbox(...) resolves apiKey / baseUrl / vaultKey from the explicit option, then the matching env var (INKBOX_API_KEY / INKBOX_BASE_URL / INKBOX_VAULT_KEY), then a ~/.inkbox/config file (key = value lines). The file fallback is handy for background/agent processes that don't inherit the shell's env, so new Inkbox() with no arguments works once the file is in place.

Behind a proxy? The SDK uses Node's fetch, which ignores HTTP_PROXY / HTTPS_PROXY / NO_PROXY by default — run with NODE_USE_ENV_PROXY=1 (Node 22.21+ / 24+) or, on older versions, configure a proxy-aware fetch dispatcher (e.g. undici's EnvHttpProxyAgent). A request that can't connect throws InkboxConnectionError naming the URL and underlying cause, with this hint attached when proxy variables are set but unused.

Directional communication permissions

Requires SDK 0.7.3 or later.

Mail and phone rules accept direction: "inbound" | "outbound" | "both" (RuleDirection). Inbound means communication from the counterparty to the agent; outbound means communication from the agent to the counterparty. Phone policy also applies to iMessage.

import { Inkbox, MailRuleAction, MailRuleMatchType } from "@inkbox/sdk";

const inkbox = new Inkbox();
const identity = await inkbox.getIdentity("support-bot");
await identity.update({
  mailInboundFilterMode: "blacklist",
  mailOutboundFilterMode: "whitelist",
});
const rule = await identity.createMailContactRule({
  action: MailRuleAction.ALLOW,
  matchType: MailRuleMatchType.EXACT_EMAIL,
  matchTarget: "[email protected]",
  direction: "outbound",
});
await identity.updateMailContactRule(rule.id, {
  action: MailRuleAction.BLOCK,
  applyTo: "outbound",
});
  • Create omits direction by default, meaning Both. PATCH omission preserves coverage; action-only and direction-only updates are supported.
  • applyTo requires action, excludes direction, and changes one covered side atomically while preserving the opposite side. The response remains one rule; refresh the list to see any retained opposite-side rule.
  • List and listAll accept direction. Inbound/outbound include Both rules; Both selects only Both rules. A2A retains its separate exact-direction behavior.
  • Compatible coverage may consolidate under an existing ID. Trust the returned ID and direction; a successful create does not necessarily allocate a new ID.
  • Identity effective fields are mailInboundFilterMode, mailOutboundFilterMode, phoneInboundFilterMode, and phoneOutboundFilterMode. Shared mode writes set both directions. Shared reads report the common effective mode when equal, otherwise the stored shared baseline. Do not combine shared and directional mode writes for the same channel.
  • Legacy mailbox, number, and iMessage rule resources remain supported. Rule parsers default missing direction to Both; effective modes fall back to shared values on older responses. Directional operations require a supporting API.

Contact access groups expose optional inboundContactable and outboundContactable lists. Legacy contactable means outbound on reads and both directions on writes. Omit unchanged lists; an empty directional list blocks that side's current addresses. Do not mix legacy and directional lists in one group or supply null.

await inkbox.contacts.access.update("support-bot", "contact-id", {
  email: { inboundContactable: ["[email protected]"], outboundContactable: [] },
});

Address policy results also expose inboundAction, outboundAction, allowedInbound, and allowedOutbound. Guarded address edits accept direction and expectedInboundAction/expectedOutboundAction for pair-aware updates. A one-way edit needs only its corresponding expected action; Both needs the pair when expectedAction is omitted.

contacts.permissions.get/update expose inboundEmails, outboundEmails, inboundPhones, and outboundPhones address-to-boolean maps. Legacy emails and phones reads project outbound permissions; their writes still affect both directions. Do not mix a shared map with a directional map for the same channel, or supply null. Each map accepts up to 50 addresses; false and empty maps remain explicit choices. Omitted maps preserve the existing choices.

These maps also work in contacts.create({ permissions: ... }). Alternatively, initial permissions.addresses accepts up to 200 { kind, value, action, direction? } decisions: 50 emails and 50 phone numbers, each with two directions. This additive create overload accepts CreateContactWithAddressPermissionsOptions with ContactCreateAddressPermissions, preserving existing boolean permission types. Use one permission shape per creation request: maps, access groups, or address decisions. The contact's identifier limits remain 50 per kind.

Visibility does not imply permission to send. Establishing a shared-line iMessage connection requires both effective permissions and never grants either one.

Response metadata

Existing resource methods keep their return types. To receive advisory notices alongside a result, use withResponseMetadata and make calls through its scoped client:

const response = await inkbox.withResponseMetadata(async (client) => {
  const identity = await client.getIdentity("support-bot");
  return identity.listMailContactRules({ direction: "outbound" });
});
console.log(response.data);
for (const notice of response.notices ?? []) {
  console.log(notice.code, notice.level, notice.message);
}

The result is APIResponse<T> with data and optional ResponseNotice[]. Void success becomes data: null. Notices are deduplicated by code, level, and message across the callback's requests. Concurrent and nested scopes collect independently while retaining authentication, cookies, timeout, and unlocked vault state. No extra requests are made to collect metadata.

For every completed HTTP response, including errors, downloads, and empty responses, an optional onResponse: ResponseObserver receives ResponseMetadata. It is available on InkboxOptions, SignupOptions, and A2AClient options. The SDK is silent by default; observer failures do not alter API results or retry requests.

const observed = new Inkbox({
  onResponse(metadata) {
    for (const notice of metadata.notices ?? []) console.log(notice.message);
  },
});
await observed.listIdentities();

Notices use the Inkbox-Notices JSON response header, with top-level body fallback only on declared identity, channel, and contact-permission response contracts, including identity creation and avatar upload. Avatar downloads remain binary. Unknown codes and levels remain strings. Missing, null, empty, or malformed optional metadata never changes the primary result. Errors still throw their ordinary exceptions, including agentSupport guidance; they are not returned as successful metadata envelopes.

Quick start

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

const inkbox = await new Inkbox({
  apiKey: process.env.INKBOX_API_KEY!,
  vaultKey: process.env.INKBOX_VAULT_KEY,
}).ready();

// Create an agent identity with a linked mailbox
const identity = await inkbox.createIdentity("support-bot", { displayName: "Support Bot" });
const phone = await identity.provisionPhoneNumber(); // provisions a local number

// Send email directly from the identity
await identity.sendEmail({
  to: ["[email protected]"],
  subject: "Your order has shipped",
  bodyText: "Tracking number: 1Z999AA10123456784",
});

// Place an outbound call
await identity.placeCall({
  toNumber: "+18005559999",
  clientWebsocketUrl: "wss://my-app.com/voice",
});

// Read inbox
for await (const message of identity.iterEmails()) {
  console.log(message.subject);
}

// List calls
const calls = await identity.listCalls();

// Access credentials (vault unlocked at construction)
const creds = await identity.getCredentials();
for (const login of creds.listLogins()) {
  console.log(login.name);
}

Authentication

| Option | Type | Default | Description | |---|---|---|---| | apiKey | string | required | Your ApiKey_... token | | baseUrl | string | API default | Override for self-hosting or testing | | timeoutMs | number | 30000 | Request timeout in milliseconds |


Agent Signup

Agents can self-register without a pre-existing API key. All signup methods are static — no Inkbox instance required.

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

// Sign up (public — no API key needed)
const result = await Inkbox.signup({
  humanEmail: "[email protected]",
  noteToHuman: "Hey John, this is your sales bot signing up!",
  displayName: "Sales Agent",      // optional
  agentHandle: "sales-agent",      // optional
  emailLocalPart: "sales.agent",   // optional
  harness: "claude-code",          // optional — selects matching plugin guidance
  invitationToken: process.env.INKBOX_A2A_INVITATION, // optional link or raw token
});
const apiKey = result.apiKey;          // save — shown only once
const email = result.emailAddress;     // e.g. "[email protected]"
const handle = result.agentHandle;     // e.g. "sales-agent-a1b2c3"
console.log(result.message);           // authoritative delivery/acceptance outcome

// A matching email-bound invitation can claim immediately without another email.
const alreadyClaimed = result.invitation?.status === "accepted"
  || result.claimStatus === "agent_claimed";
if (!alreadyClaimed) {
  // If the email is missing, resend before submitting its 6-digit code.
  // await Inkbox.resendSignupVerification(apiKey); // 5-minute cooldown
  await Inkbox.verifySignup(apiKey, { verificationCode: "483921" });
}

// Check status and restrictions
const status = await Inkbox.getSignupStatus(apiKey);
console.log(status.claimStatus);                    // "agent_unclaimed" or "agent_claimed"
console.log(status.restrictions.maxSendsPerDay);    // Effective 24-hour recipient-send limit

| Method | Auth | Returns | |---|---|---| | Inkbox.signup(request, options?) | None | AgentSignupResponse | | Inkbox.verifySignup(apiKey, request, options?) | API key | AgentSignupVerifyResponse | | Inkbox.resendSignupVerification(apiKey, options?) | API key | AgentSignupResendResponse | | Inkbox.getSignupStatus(apiKey, options?) | API key | AgentSignupStatusResponse |

request for signup() requires humanEmail and noteToHuman. displayName, agentHandle, emailLocalPart, harness, and invitationToken are optional. Omit invitationToken when signup is not part of an A2A connection invitation. Invitation-assisted signup and verification expose an optional invitation summary. A claimed response includes plugin guidance in message, tailored to harness when supplied.

Note: Unclaimed agents have a limited send quota and can only email the humanEmail specified at signup. After verification or human approval in the console, full capabilities are unlocked.

Note: The organizationId returned at signup may change after verification or human approval. Always use the organizationId from the most recent response (verifySignup or resendSignupVerification) rather than caching the value from the initial signup() call.


Identities

inkbox.createIdentity() and inkbox.getIdentity() return an AgentIdentity object that holds the identity's channels and exposes convenience methods scoped to those channels.

// Create and fully provision an identity
// createIdentity atomically provisions the mailbox AND the tunnel —
// both come back on the response. Phone numbers stay opt-in.
const identity = await inkbox.createIdentity("sales-bot", {
  displayName: "Sales Bot",
  description: "Sales-outreach agent",
});
const phone    = await identity.provisionPhoneNumber(); // provisions a local number

console.log(identity.emailAddress);            // [email protected]
console.log(identity.tunnel?.publicHost);      // sales-bot.inkboxwire.com
console.log(phone.number);

// Pin the identity's mailbox to a verified custom sending domain
// (bare name; see "Custom Sending Domains" below).
await inkbox.createIdentity("sales-bot-2", { sendingDomain: "mail.acme.com" });

// Provision a passthrough tunnel (tls_mode is fixed at create time)
await inkbox.createIdentity("sales-bot-pt", { tunnel: { tlsMode: "passthrough" } });

// Get an existing identity (returned with current channel state)
const identity2 = await inkbox.getIdentity("sales-bot");
await identity2.refresh();  // re-fetch channels from API

// Admin credentials list organization identities; agent-scoped credentials
// return only their own identity.
const allIdentities = await inkbox.listIdentities();

// Agent-scoped credentials discover peers through the A2A directory.
const peers = await inkbox.a2a.organizationDirectory();

// Update identity metadata or handle
await identity.update({ newHandle: "sales-bot-v2" });

// Release the phone number (carrier release + local delete). Mailbox and
// tunnel are 1:1 with the identity and can only be removed by deleting it.
await identity.releasePhoneNumber();

// Delete (cascades to mailbox + tunnel + phone-number release; revokes scoped API keys).
await identity.delete();

Mail

Mailbox imports

import { openAsBlob } from "node:fs";
import { MailImportFormat } from "@inkbox/sdk";

const file = await openAsBlob("./archive.zip");
const created = await inkbox.mailboxes.imports.create("[email protected]", {
  sourceFormat: MailImportFormat.ZIP,
  originalAddresses: ["[email protected]"],
});
await inkbox.mailboxes.imports.upload(created.upload, file);
await inkbox.mailboxes.imports.start("[email protected]", created.job.id);
const job = await inkbox.mailboxes.imports.wait("[email protected]", created.job.id, {
  timeoutMs: 3_600_000,
  pollIntervalMs: 5_000,
});

Supported formats are auto, mbox, eml, and zip. A ZIP may hold .eml and/or .mbox files (a Gmail Takeout ZIP imports as-is); entries that are not mail, including nested archives, are ignored. wait fetches immediately, polls every five seconds by default, and returns every terminal state, including failed and cancelled. A local timeout does not cancel the job. Counters are cumulative and never go backwards, but they can sit unchanged while a large message is processed and never yield a percentage. Jobs run one at a time per organization and share overall import capacity, so a long queued stretch is normal. Unsafe imported content may be rejected and reported in messagesRejectedUnsafe.

Upload targets expire after 5 minutes; call refreshUploadTarget and upload again if one expires, or cancel the job so the mailbox is not held by an upload that never landed. Other limits: 1 GiB per upload, 50 MiB per message, 100,000 messages and 20 originalAddresses per job, 65,000 entries per ZIP, 20 import jobs per organization per 24 hours (MailImportQuotaExceededError carries retryAfterSeconds), and one in-flight import per mailbox.

// Send an email (plain text and/or HTML)
const sent = await identity.sendEmail({
  to: ["[email protected]"],
  subject: "Hello from Inkbox",
  bodyText: "Hi there!",
  bodyHtml: "<p>Hi there!</p>",
  cc: ["[email protected]"],
  bcc: ["[email protected]"],
});

// Send a threaded reply
await identity.sendEmail({
  to: ["[email protected]"],
  subject: `Re: ${sent.subject}`,
  bodyText: "Following up!",
  inReplyToMessageId: sent.id,
});

// Send with attachments
await identity.sendEmail({
  to: ["[email protected]"],
  subject: "See attached",
  bodyText: "Please find the file attached.",
  attachments: [{
    filename: "report.pdf",
    contentType: "application/pdf",
    contentBase64: "<base64-encoded-content>",
  }],
});

// Inline images: set contentId on an image attachment and reference it from
// bodyHtml as cid:<contentId>. Requires bodyHtml + an image/* contentType, a
// unique id per send, and is not supported on forwards.
await identity.sendEmail({
  to: ["[email protected]"],
  subject: "Weekly report",
  bodyHtml: '<p>Revenue:</p><img src="cid:chart">',
  attachments: [{
    filename: "chart.png",
    contentType: "image/png",
    contentBase64: "<base64-encoded-content>",
    contentId: "chart",
  }],
});

// Track opens: embed a tracking pixel when an HTML body is present. Opens
// surface on the returned Message as firstOpenedAt / openCount.
const tracked = await identity.sendEmail({
  to: ["[email protected]"],
  subject: "Did you see this?",
  bodyHtml: "<p>Please review.</p>",
  trackOpens: true,
});
console.log(tracked.firstOpenedAt, tracked.openCount);
// Caveats: plain-text-only sends aren't tracked;
// openCount is approximate (proxy prefetch inflates it, the per-window
// debounce collapses repeats — so it can read above or below the true
// count); prefer firstOpenedAt. Pixels can also raise spam scores.

// Drafts accept incomplete content. Each draft response includes its generation.
const draft = await identity.createEmailDraft({
  subject: "Review requested",
  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: ["[email protected]"] },
  subject: null, // explicit null clears; omit the field to leave it unchanged
});

current = await inkbox.drafts.addAttachments(identity.emailAddress!, current.id,
  current.generation, [{
    filename: "report.txt",
    contentType: "text/plain",
    contentBase64: "cmVwb3J0",
  }]);
const part = current.attachmentMetadata[0];
const downloaded = 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 sentDraft = await identity.sendEmailDraft(current.id, current.generation);

// Iterate inbox (paginated automatically)
for await (const msg of identity.iterEmails()) {
  console.log(msg.subject, msg.fromAddress, msg.isRead);
}

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

// Iterate only unread emails
for await (const msg of identity.iterUnreadEmails()) {
  console.log(msg.subject);
}

// Mark messages as read (or unread)
const unread: string[] = [];
for await (const msg of identity.iterUnreadEmails()) unread.push(msg.id);
await identity.markEmailsRead(unread);
await identity.markEmailsUnread(["message-uuid"]);

// Get all emails in a thread (threadId comes from msg.threadId)
const thread = await identity.getThread(msg.threadId!);
for (const m of thread.messages) {
  console.log(m.subject, m.fromAddress);
}

Drafts use the same Drafts folder as a connected mail client, so edits are visible in both directions. Always use the latest returned generation; attachment partIndex values belong to the generation that returned them, so refresh attachment metadata after an edit.

A successful send returns a Message and removes the draft. An exact-generation retry may return the same sent message. Draft conflicts are InkboxAPIError responses with statusCode === 409 and a structured detail.error. Refresh on draft_generation_conflict and retry the same draft ID and generation on draft_send_in_progress. Do not resend draft_delivery_uncertain; after checking sent mail, duplicate or delete that draft instead.

Fetching a single inbound message by id (inkbox.messages.get, below) with an API key marks it read server-side (isRead becomes true); iterating via iterEmails / iterUnreadEmails does not, so markEmailsRead stays the way to clear unread in list-only workflows. This server-side isRead (the agent consumed the message via the API) is distinct from firstOpenedAt (the recipient's mail client loaded the tracking pixel).

Mailbox storage

Every mailbox has a plan storage cap. Sends, reply-alls, and forwards that would push it over the cap are rejected with a 402StorageLimitExceededError:

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

try {
  await identity.sendEmail({ to: ["[email protected]"], subject: "Hi", bodyText: "…" });
} catch (err) {
  if (err instanceof StorageLimitExceededError) {
    console.log(err.message);                    // human-readable, includes the limit
    console.log(err.limitBytes, err.upgradeUrl); // e.g. 2147483648, https://…?tab=billing
    // Free space (reclaim is immediate) or upgrade the plan:
    await inkbox.messages.delete(mailbox.emailAddress, "message-uuid");
    await inkbox.threads.delete(mailbox.emailAddress, "thread-uuid");
  }
}

Current usage lives on the mailbox (inkbox.mailboxes.list() / .get()):

const mailbox = await inkbox.mailboxes.get("[email protected]");
console.log(mailbox.storageUsedBytes);  // e.g. 1288490188
console.log(mailbox.storageLimitBytes); // e.g. 2147483648 (2 GiB), or null if unresolved

const usedGiB = mailbox.storageUsedBytes / 1024 ** 3; // caps are binary — GiB, not GB

The caps are binary: 2 GiB is 2 * 1024 ** 3 = 2,147,483,648 bytes. Divide by 1024 and label the result GiB/MiB.

Free plan: a footer is appended to the stored body of outgoing mail, so what you read back with inkbox.messages.get(...) is not byte-for-byte what you sent — a sentBody === fetchedBody round-trip assertion will fail on Free plans (a send with no body comes back with the footer as its body). Paid plans are unaffected.


Phone

import {
  CallMode,
  HostedAgentAuthorityMode,
  OnVoicemail,
} from "@inkbox/sdk";

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

// Let Voice AI handle the call using this identity's saved authority.
// Hosted-agent calls leave a voicemail by default (onVoicemail=leave_message);
// pass voicemailMessage to control what is said, or hang_up / ignore to end
// the call at the beep or skip detection entirely.
const hostedCall = await identity.placeCall({
  toNumber: "+15551234567",
  mode: CallMode.HOSTED_AGENT,
  reason: "Coordinate the appointment and send confirmations.",
  onVoicemail: OnVoicemail.LEAVE_MESSAGE,
  voicemailMessage: "Hi, this is Ava calling about your appointment. Please call us back.",
});
console.log(hostedCall.onVoicemail); // "leave_message"

// Set the saved default for future inbound and outbound Voice AI calls.
// Changing the saved default requires an admin API key.
await identity.setHostedAgentAuthorityMode({
  authorityMode: HostedAgentAuthorityMode.YOLO,
});

// Per-call overrides are optional. CONTACT_SCOPED always downscopes. YOLO
// requires an admin credential unless the saved authority is already YOLO.
const scopedCall = await identity.placeCall({
  toNumber: "+15551234567",
  mode: CallMode.HOSTED_AGENT,
  reason: "Confirm only this caller's appointment.",
  hostedAgentAuthorityMode: HostedAgentAuthorityMode.CONTACT_SCOPED,
});

// Discover Voice AI voices for your organization; no identity ID is needed.
const catalog = await inkbox.hostedAgent.listVoices();
console.log(catalog.defaultVoice);
for (const voice of catalog.voices) {
  console.log(voice.id, voice.name, voice.description, voice.available, voice.previewUrl);
}

// Keep unavailable entries for display, but select an available voice.
const selectedVoice = catalog.voices.find((voice) => voice.available);
if (selectedVoice) {
  const config = await identity.getHostedAgentConfig();
  // Full replacement: preserve instructions when changing only the voice.
  await identity.setHostedAgentConfig({
    voice: selectedVoice.id,
    instructions: config.instructions ?? undefined,
  });
}

// List calls (paginated)
const calls = await identity.listCalls({ limit: 10, offset: 0 });
for (const c of calls) {
  console.log(c.id, c.direction, c.remotePhoneNumber, c.status);
}

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

// Inspect tool activity for a Voice AI call
const activity = await identity.listToolInvocations(calls[0].id, {
  limit: 50,
  offset: 0,
});
for (const invocation of activity.items) {
  console.log(invocation.toolName, invocation.status);
}

// Read transcripts across all recent calls
const recentCalls = await identity.listCalls({ limit: 10 });
for (const call of recentCalls) {
  const segs = await identity.listTranscripts(call.id);
  if (!segs.length) continue;
  console.log(`\n--- Call ${call.id} (${call.direction}) ---`);
  for (const t of segs) {
    console.log(`  [${t.party.padEnd(6)}] ${t.text}`);
  }
}

// Filter to only the remote party's speech
const remoteOnly = segments.filter(t => t.party === "remote");
for (const t of remoteOnly) console.log(t.text);

// Search transcripts across a phone number (org-level)
const hits = await inkbox.phoneNumbers.searchTranscripts(phone.id, { q: "refund", party: "remote" });
for (const t of hits) {
  console.log(`[${t.party}] ${t.text}`);
}

Text Messages (SMS/MMS)

Send and receive SMS/MMS through the identity's assigned phone number.

Outbound SMS rules (read before sending):

  • Each sender phone number is rate-limited to 100 recipient sends per rolling 24-hour window. 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.
  • A new local number takes ~10-15 minutes for the 10DLC campaign to propagate at the carrier — phoneNumber.smsStatus reads "pending" until then, and sends will return 409 sender_sms_pending.
  • The recipient must have texted START to any number within your organization to opt in. Unknown recipients will fail with 403 recipient_not_opted_in; recipients who later send STOP flip to 403 recipient_opted_out. You can inspect consent state directly via inkbox.smsOptIns — see SMS Opt-Ins.
  • 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 and campaigns lift the default per-number cap to the carrier-assigned tier.

TypeScript users: group rows can legitimately have no single remote party, so text/conversation/webhook remotePhoneNumber / remote_phone_number fields are typed as string | null. One-to-one traffic still populates the remote number.

// Send SMS/MMS. 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 uses the same method with an array of recipients.
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
const texts = await identity.listTexts({ limit: 20 });
for (const t of texts) {
  console.log(t.remotePhoneNumber, t.text, t.isRead);
}

// Filter to unread only
const unread = await identity.listTexts({ isRead: false });

// Get a single text
const text = await identity.getText("text-uuid");
console.log(text.type);  // "sms" or "mms"
if (text.media) {         // MMS 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 as read
await identity.markTextRead("text-uuid");
await identity.markTextConversationRead("+15551234567");

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

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 — open to admin API keys and user session JWTs.

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

// List the 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);

Writes — admin-only, and only if your org runs its own active, customer-managed 10DLC campaign. Orgs on the Inkbox-default campaign share consent state and get a 409 customer_campaign_required on write attempts. Writes record an audit event with source: "api".

// Record consent captured outside of STOP/START (signup form, paper waiver, etc.)
await inkbox.smsOptIns.optIn("+15551234567");

// Honor an opt-out collected outside of inbound STOP
await inkbox.smsOptIns.optOut("+15551234567");

iMessage

Chat with humans over the shared Inkbox router or a dedicated iMessage line. iMessage is opt-in per identity (imessageEnabled). On shared service, the human texts first. Dedicated lines may initiate conversations, subject to consent and rate limits.

import {
  DedicatedIMessageNumberInventoryPendingError,
  DedicatedIMessageNumberQuotaExceededError,
  IdempotencyKeyReusedError,
  IMessageRuleAction,
  IMessageSendStyle,
} from "@inkbox/sdk";

// Shared service: opt an identity in at create time or later.
const identity = await inkbox.createIdentity("my-agent", { imessageEnabled: true });

// Resolve the router number at runtime — never hardcode it.
const router = await inkbox.imessages.getTriageNumber();
console.log(router.number, router.connectCommand); // e.g. 'connect @my-agent'

// Once a human has connected and messaged, read and reply.
const convos = await identity.listIMessageConversations({ limit: 20 });
const msgs = await identity.listIMessages({ conversationId: convos[0].id });
await identity.sendIMessage({
  conversationId: convos[0].id,
  text: "On it — give me two minutes.",
});

// Who is currently connected? (Disconnected conversations stay readable
// with assignmentStatus === "released"; sends into them return 409.)
const connections = await identity.listIMessageAssignments();
await identity.releaseIMessageAssignment(connections[0].id); // admin key only; they can reconnect via triage

// Tapbacks target inbound one-to-one or group messages by messageId. The seven
// named reactions include "eyes" ("custom" is rejected locally on send), and a
// new tapback replaces your previous one on the same message part. Group read
// receipts and typing indicators remain unsupported and return 409.
const sentReaction = await identity.sendIMessageReaction({ messageId: msgs[0].id, reaction: "like" });

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

// Read receipts, typing indicator, media.
await identity.markIMessageConversationRead(convos[0].id);
await identity.sendIMessageTyping(convos[0].id);
const upload = await identity.uploadIMessageMedia({
  content: fileBytes,
  filename: "chart.png",
  contentType: "image/png",
});
await identity.sendIMessage({ conversationId: convos[0].id, mediaUrls: [upload.mediaUrl] });

// Per-identity allow/block rules, interpreted via imessageFilterMode.
await inkbox.imessageContactRules.create("my-agent", {
  action: IMessageRuleAction.BLOCK,
  matchTarget: "+15555550999",
});

// List every dedicated line owned by the organization. Unattached lines
// have null agentIdentityId and agentHandle fields.
const numbers = await inkbox.imessages.listNumbers();
for (const number of numbers) {
  console.log(number.number, number.agentHandle);
}

// Claim an unattached number for the organization. Generate the key once and
// reuse it if the request has an ambiguous outcome; a new key can claim again.
const claimKey = crypto.randomUUID();
try {
  const claimed = await inkbox.imessages.claimNumber({
    idempotencyKey: claimKey,
  });
  console.log(claimed.number);
} catch (err) {
  if (err instanceof DedicatedIMessageNumberQuotaExceededError) {
    console.error(err.message, err.upgradeUrl);
  } else if (err instanceof DedicatedIMessageNumberInventoryPendingError) {
    console.error(`Try again in ${err.retryAfterSeconds} seconds`);
  } else if (err instanceof IdempotencyKeyReusedError) {
    console.error(err.message);
  } else {
    throw err;
  }
}

// Claim and attach atomically during identity creation.
const dedicatedIdentity = await inkbox.createIdentity("outreach-agent", {
  imessageEnabled: true,
  claimIMessageNumber: true,
});
console.log(dedicatedIdentity.imessageNumber?.number);

// A dedicated line can create or reuse an exact-participant group. Keep
// the returned conversationId for later replies. An ambiguous best-known match
// returns 409 instead of choosing a conversation.
const group = await dedicatedIdentity.sendIMessage({
  to: ["+15551234567", "+15557654321"],
  text: "Welcome to the group!",
  mediaUrls: ["https://example.com/group-photo.jpg"],
  sendStyle: IMessageSendStyle.CONFETTI,
});
await dedicatedIdentity.sendIMessage({
  conversationId: group.conversationId,
  text: "Following up in the same conversation.",
  mediaUrls: ["https://example.com/follow-up.jpg"],
  sendStyle: IMessageSendStyle.LASERS,
});
const groupConvos = await dedicatedIdentity.listIMessageConversations({ includeGroups: true });
const groupMessages = await dedicatedIdentity.listIMessages({ includeGroups: true });
console.log(group.isGroup, group.participants, group.recipients);
// groupCreationStatus is "creating", "not_created", or "ready". A rejected
// initial creation leaves this same local conversation at "not_created"; send
// again with its conversationId to retry. Success binds the remote thread and
// changes the status to "ready".
console.log(groupConvos[0].groupCreationStatus);
// Groups accept the same 13 IMessageSendStyle values as one-to-one sends on
// both creation and conversationId replies, with or without the media URL.

// Claim and atomically attach/swap during update. To attach an already-owned
// number, pass imessageNumberId instead. Pass imessageNumberId: null to move
// back to shared service. claimIMessageNumber and imessageNumberId cannot be
// combined in one update.
await identity.update({
  claimIMessageNumber: true,
  idempotencyKey: crypto.randomUUID(),
});

Inbound messages, tapbacks, and outbound delivery status arrive via identity-owned webhook subscriptions — see Webhooks for the five imessage.* event types.


Agent-to-Agent (A2A)

With an admin-scoped API key, create and manage an invitation that connects an external agent to a fixed bundle of peers:

const invite = await inkbox.a2aInvitations.create({
  peerAgentHandles: ["support", "billing"],
  recipientEmail: "[email protected]",
});
const page = await inkbox.a2aInvitations.list({ status: "pending" });
await inkbox.a2aInvitations.revoke(invite.id);

// No API key is required to review an invitation before signup or acceptance:
const preview = await Inkbox.previewA2AInvitation(
  process.env.INKBOX_A2A_INVITATION!,
);

// With a claimed agent-scoped key:
await inkbox.a2aInvitations.accept(process.env.INKBOX_A2A_INVITATION!);

An unbound create returns invitationToken, invitationUrl, and agentHandoffPrompt when available. accept() and signup accept either the exact-origin share URL or a raw token; extractA2AInvitationToken() is exported for local normalization. Only the raw token is sent to the API. A recipient-email-bound create emails the recipient and omits capability fields. Raw and extracted tokens must match a2ai_ followed by 43 URL-safe characters. Share links require HTTPS, except for configured localhost/127.0.0.1 URLs.

const identity = await inkbox.getIdentity("coordinator");

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

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

// Omit direction for the receiver inbox. Use "outbound" for requested work
// or "both" for the complete identity-scoped history.
const page = await identity.a2aTasks({
  direction: "both",
  requesterHandle: "coordinator",
  workerHandle: "researcher",
  state: "working",
  q: "quarterly report",
  since: "2026-07-01T00:00:00Z",
  limit: 25,
});

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

// Async iterators preserve every filter while following opaque cursors.
for await (const message of identity.iterA2AMessages({
  direction: "outbound",
  workerHandle: "researcher",
  role: "agent",
  q: "revenue",
})) {
  console.log(message.taskId, message.taskState, message.parts);
}

// The outbound alias is convenient when only requested work is needed.
const sent = await identity.a2aSentTasks({ workerHandle: "researcher" });

// Context caller/target stays in original-open orientation. Each nested task
// carries its own caller and target, so both directions can run concurrently.
for (const context of (await identity.a2aContexts({ direction: "both" })).items) {
  console.log(context.name, context.id);
}

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

Task keyword filtering returns tasks containing a matching message. Message filtering returns the individual matching messages with task, context, requester, and worker provenance. Search covers string and numeric content values from text and data parts, excludes metadata, and is newest-first rather than relevance-ranked. role is the message author (caller or agent), independent of task direction. Task detail exposes message history and current state.

Directory methods support q, cursor, and limit; async iterator variants follow all pages. Receiver enablement, public egress, and advertised skills accept the identity's agent-scoped key. Public discoverability, filter-mode, and contact-rule create/update/delete operations require an admin API key. Use a2aResetSkills() to restore default skills.

New contexts immediately expose the persisted name New A2A Session. That exact default may be replaced asynchronously with a short name based on the first task message. Either participant can rename the shared context at any time; a non-default name is not replaced by automatic naming. Applications should display the returned value as-is. Context-level caller and target always identify the original opener and recipient. Nested task participants are authoritative for each task's direction, and multiple tasks can run concurrently in either direction.

The standard client reuses the existing contextId option. Supplying a context without a task starts a sibling task; supplying taskId continues that specific task:

const client = await identity.a2aClient();
const target = await client.fetchCard(
  "https://example.test/a2a/researcher/card",
);
const result = await client.send(target, {
  text: "Review the updated findings",
  contextId: "context-uuid",
});

Cross-endpoint context reuse is supported between Inkbox identities. External A2A services may define different context reuse behavior.

Rule directions are inbound, outbound, or both. Same-organization and public discovery may imply admission; private cross-organization calls must pass the requester's outbound policy and the worker's inbound policy. both applies in either role, and explicit blocks always win.

The standard client authenticates Agent Card retrieval on the configured Inkbox origin. It never sends the Inkbox API key to external card or RPC origins. An explicit external credential is sent only to a same-origin RPC URL.

Credentials

Access credentials stored in the vault through the agent-facing credentials surface. The vault must be unlocked first.

// Unlock the vault (once per session)
await inkbox.vault.unlock("my-Vault-key-01!");

const identity = await inkbox.getIdentity("my-agent");
const creds = await identity.getCredentials();

// Discovery — list credentials this identity has access to
for (const login of creds.listLogins()) {
  console.log(login.name, (login.payload as LoginPayload).username);
}

for (const key of creds.listApiKeys()) {
  console.log(key.name, (key.payload as APIKeyPayload).accessKey);
}

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

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

Vault Management

Manage the encrypted vault at the org level. Access via inkbox.vault.

// Get vault metadata (key counts, secret counts)
const info = await inkbox.vault.info();
console.log(info.secretCount, info.keyCount);

// Initialize a new vault (creates primary key + recovery keys)
const result = await inkbox.vault.initialize("my-Vault-key-01!");
for (const key of result.recoveryKeys) {
  console.log(key.recoveryCode); // save these immediately
}

// Rotate the vault password
await inkbox.vault.updateKey({
  newVaultKey: "new-Vault-key-02!",
  currentVaultKey: "my-Vault-key-01!",
});

// Rotate using a recovery code (if primary key is lost)
await inkbox.vault.updateKey({
  newVaultKey: "new-Vault-key-02!",
  recoveryCode: "recovery-code-here",
});

// List vault keys
const keys = await inkbox.vault.listKeys();                          // all keys
const primaryKeys = await inkbox.vault.listKeys({ keyType: "PRIMARY" });
const recoveryKeys = await inkbox.vault.listKeys({ keyType: "RECOVERY" });

// List secrets (metadata only — no encrypted payloads)
const secrets = await inkbox.vault.listSecrets();
const logins  = await inkbox.vault.listSecrets({ secretType: "login" });

// Delete a secret
await inkbox.vault.deleteSecret("secret-uuid");

// Unlock the vault for decryption (returns an UnlockedVault)
const unlocked = await inkbox.vault.unlock("my-Vault-key-01!");
const secret = await unlocked.getSecret("secret-uuid");
console.log(secret.name, secret.payload);

Access control

Control which identities can access which secrets.

// List access rules for a secret
const rules = await inkbox.vault.listAccessRules("secret-uuid");
for (const rule of rules) {
  console.log(rule.identityId);
}

// Grant an identity access to a secret
await inkbox.vault.grantAccess("secret-uuid", "identity-uuid");

// Revoke access
await inkbox.vault.revokeAccess("secret-uuid", "identity-uuid");

Identity Secret Management

Manage vault secrets scoped to a specific identity. These methods create secrets and automatically grant the identity access.

const identity = await inkbox.getIdentity("my-agent");

// Create a secret and auto-grant this identity access
const secret = await identity.createSecret({
  name: "CRM Login",
  payload: { type: "login", username: "[email protected]", password: "s3cret" },
  description: "CRM service account",
});

// Fetch and decrypt a secret
const decrypted = await identity.getSecret(secret.id);
console.log(decrypted.payload);

// Delete a secret
await identity.deleteSecret(secret.id);

// Revoke this identity's access (without deleting the secret)
await identity.revokeCredentialAccess(secret.id);

TOTP (one-time passwords)

Add, remove, and generate TOTP codes for login secrets.

// Add TOTP to a login secret (accepts otpauth:// URI or TOTPConfig)
await identity.setTotp(secret.id, "otpauth://totp/Example:user?secret=JBSWY3DPEHPK3PXP&issuer=Example");

// Generate the current TOTP code
const code = await identity.getTotpCode(secret.id);
console.log(code.code, code.expiresIn);

// Remove TOTP from a secret
await identity.removeTotp(secret.id);

Org-level Messages and Threads

Access messages and threads directly without going through an identity. Useful for org-wide operations.

// List messages for a mailbox (paginated automatically)
for await (const msg of inkbox.messages.list("[email protected]")) {
  console.log(msg.subject);
}

// Get a single message with full body. Fetching an *inbound* message with
// an API key marks it read server-side (isRead -> true); list, thread, and
// attachment routes do not. Use markRead for list-only workflows.
const detail = await inkbox.messages.get("[email protected]", "message-uuid");
console.log(detail.bodyText);

// Send a message from a mailbox
await inkbox.messages.send("[email protected]", {
  to: ["[email protected]"],
  subject: "Hello",
  bodyText: "Hi there!",
});

// Update message flags
await inkbox.messages.updateFlags("[email protected]", "message-uuid", { isRead: true });
await inkbox.messages.markRead("[email protected]", "message-uuid");
await inkbox.messages.markUnread("[email protected]", "message-uuid");
await inkbox.messages.star("[email protected]", "message-uuid");
await inkbox.messages.unstar("[email protected]", "message-uuid");

// Delete a message
await inkbox.messages.delete("[email protected]", "message-uuid");

// Get a temporary signed URL for an attachment
const attachment = await inkbox.messages.getAttachment("[email protected]", "message-uuid", "report.pdf");
console.log(attachment.url);

// List threads (paginated automatically)
for await (const thread of inkbox.threads.list("[email protected]")) {
  console.log(thread.subject, thread.messageCount);
}

// Get a thread with all messages
const thread = await inkbox.threads.get("[email protected]", "thread-uuid");

// Delete a thread
await inkbox.threads.delete("[email protected]", "thread-uuid");

Org-level Calls

Calls are identity-scoped. Access them via inkbox.calls; transcripts are folded onto the same resource as inkbox.calls.transcripts(callId).

// List calls (agent-scoped keys resolve their own identity; admin/JWT
// keys must pass agentIdentityId).
const calls = await inkbox.calls.list({ limit: 10 });
for (const call of calls) {
  console.log(call.id, call.direction, call.status, call.origin);
}

// List calls for a specific identity (admin/JWT)
const scoped = await inkbox.calls.list({ agentIdentityId: "identity-uuid", limit: 10 });

// Get a single call
const call = await inkbox.calls.get("call-uuid");

// Place an outbound call from a dedicated number
const placed = await inkbox.calls.place({
  fromNumber: "+18335794607",
  toNumber: "+15551234567",
  clientWebsocketUrl: "wss://example.com/ws",
});

// Place an outbound call over the shared iMessage-number pool
import { CallOrigin } from "@inkbox/sdk";
const shared = await inkbox.calls.place({
  toNumber: "+15551234567",
  origination: CallOrigin.SHARED_IMESSAGE_NUMBER,
  agentIdentityId: "identity-uuid",
});

// List transcript segments for a call
const segments = await inkbox.calls.transcripts("call-uuid");
for (const t of segments) {
  console.log(`[${t.party}] ${t.text}`);
}

Incoming-call routing

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

// Read the current incoming-call config
const config = await inkbox.incomingCallAction.get();

// Route incoming calls to a webhook
await inkbox.incomingCallAction.set({
  incomingCallAction: IncomingCallAction.WEBHOOK,
  incomingCallWebhookUrl: "https://your-agent.example.com/incoming-call",
});

// Forward every incoming call for this identity to a complete E.164 number
await inkbox.incomingCallAction.set({
  incomingCallAction: IncomingCallAction.FORWARD,
  forwardingTargetType: ForwardingTargetType.PHONE,
  forwardingPhoneNumber: "+14155550100",
});

// Forwarding attempts are chronological and separate from call.status
for (const forwarding of (await inkbox.calls.get("call-uuid")).forwardings) {
  console.log(forwarding.status, forwarding.target);
}

Org-level Mailboxes

Mailboxes are provisioned atomically by inkbox.createIdentity(...) and removed by identity.delete() (cascade). The inkbox.mailboxes surface is read + update + search only.

// List all mailboxes in the organisation
const mailboxes = await inkbox.mailboxes.list();

// Get a specific mailbox
const mb = await inkbox.mailboxes.get("[email protected]");
console.log(mb.emailAddress);
console.log(mb.sendingDomain);  // bare domain the mailbox sends from
console.log(mb.agentIdentityId); // non-null for live customer mailboxes (1:1 invariant)
console.log(mb.storageUsedBytes);  // bytes currently stored
console.log(mb.storageLimitBytes); // plan cap in bytes (binary GiB), or null

// Filter mode now lives on the agent identity — set it via
// identity.update({ mailFilterMode: ... }). display_name likewise moved
// to the identity; the mailbox PATCH endpoint hard-rejects display_name
// with a 422. To attach a webhook receiver, see "Webhooks" below.
const supportAgent = await inkbox.getIdentity("support-agent");
await supportAgent.update({ mailFilterMode: "whitelist" }); // admin-scoped key only
// (deprecated) await inkbox.mailboxes.update(mb.emailAddress, { filterMode: "whitelist" });

// Full-text search across messages in a mailbox
const results = await inkbox.mailboxes.search(mb.emailAddress, { q: "invoice", limit: 20 });
for (const msg of results) {
  console.log(msg.subject, msg.fromAddress);
}

// To remove a mailbox, delete its owning identity (cascades to the
// linked mailbox AND tunnel; revokes scoped API keys):
await (await inkbox.getIdentity("support-agent")).delete();

Custom Sending 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 sendingDomain to createIdentity. Domain registration, DNS records, verification, DKIM rotation, and deletion stay in the console.

import { SendingDomainStatus } from "@inkbox/sdk";

// List custom sending domains for the org (optionally filter by status)
const verified = await inkbox.domains.list({ status: SendingDomainStatus.VERIFIED });
for (const d of verified) {
  console.log(d.id, d.domain, d.status, d.isDefault);
}

// Set the org default — admin-scoped API key only.
// 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 revert.
await inkbox.domains.setDefault("inkboxmail.com");  // -> null

Mail clients (IMAP/SMTP)

An Inkbox inbox can also 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 directly.

| 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. [email protected]) | | Password | an identity-scoped API key (ApiKey_...) |

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

Two constraints that bite in practice:

  • From must be the authenticated inbox address, and exactly one address. Aliases and "send as" identities 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 the plan.

If your client saves its own copy of sent messages, leave that setting on: Inkbox recognizes the copy as the message it already stored, so you get one Sent entry, charged against your storage cap once.

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


Org-level Phone Numbers

Read, search, and release phone numbers org-wide via inkbox.phoneNumbers. Provisioning still goes through an identity — pass agentHandle so the new number is bound to it from the start.

// List all phone numbers in the organisation
const numbers = await inkbox.phoneNumbers.list();

// Get a specific phone number by ID
const number = await inkbox.phoneNumbers.get("phone-number-uuid");

// Provision a new number
const num   = await inkbox.phoneNumbers.provision({ agentHandle: "sales-bot" }); // local by default
const inNy  = await inkbox.phoneNumbers.provision({ agentHandle: "sales-bot", state: "NY" });

// Update incoming call behaviour
await inkbox.phoneNumbers.update(num.id, {
  incomingCallAction: "webhook",
  incomingCallWebhookUrl: "https://example.com/calls",
});
await inkbox.phoneNumbers.update(num.id, {
  incomingCallAction: "auto_accept",
  clientWebsocketUrl: "wss://example.com/ws",
});

// Full-text search across transcripts
const hits = await inkbox.phoneNumbers.searchTranscripts(num.id, { q: "refund", party: "remote" });
for (const t of hits) {
  console.log(`[${t.party}] ${t.text}`);
}

// Release a number
await inkbox.phoneNumbers.release(num.id);

Tunnels

The Node-only data-plane runtime is exported separately so the main SDK remains browser-safe:

import { connect } from "@inkbox/sdk/tunnels/connect";

const listener = await connect(inkbox, {
  name: "my-app",
  forwardTo: "http://127.0.0.1:8080",
});

console.log(listener.publicUrl);
console.log(listener.status);          // idle, connecting, connected, ...
console.log(listener.isConnected);     // local runtime liveness
console.log(listener.lastConnectedAt); // latest successful HELLO, or null
await listener.wait();

Transient connect, HELLO, transport, and PING failures retry with bounded establishment and exponential backoff. status is idle, connecting, connected, reconnecting, closed, or superseded; the timestamp remains available while reconnecting. These values describe the local listener, while listener.tunnel remains the bootstrap resource snapshot. Authentication, takeover, and unexpected fatal failures reject both serveForever() and wait(). Attach a rejection handler when calling serveForever() without awaiting it.

For in-process WebSocket handlers, planned drain throws WsServerDraining (4500) and unplanned tunnel connection loss throws WsConnectionLost (1011). Both extend WsClosed and advise the peer to reconnect. URL-forwarded WebSockets receive the matching CLOSE code on their local upstream connection.


Webhooks

Webhook delivery uses a dedicated subscription resource. Each subscription names exactly one owner (a mailbox, a phone number, or an agent identity for iMessage), one HTTPS destination URL, and a non-empty subset of the catalog's event types. Multiple subscriptions on the same owner fan out independently.

The one exception is phone.incoming_call, which is a synchronous control-plane callback (the response body decides whether Inkbox answers). That URL still lives on the phone-number resource as incomingCallWebhookUrl.

Subscribing to mail, text, or iMessage events

// Mail subscription: pick the message.* events you want.
await inkbox.webhooks.subscriptions.create({
  mailboxId: mb.id,
  url: "https://example.com/hook",
  eventTypes: ["message.received", "message.bounced"],
});

// Text subscription: pick the text.* events you want.
await inkbox.webhooks.subscriptions.create({
  phoneNumberId: number.id,
  url: "https://example.com/texts",
  eventTypes: [
    "text.received",
    "text.sent",
    "text.delivered",
    "text.delivery_failed",
    "text.delivery_unconfirmed",
  ],
});

// iMessage subscription: owned by the agent identity (the shared
// pool lines aren't org resources).
await inkbox.webhooks.subscriptions.create({
  agentIdentityId: identity.id,
  url: "https://example.com/imessage",
  eventTypes: [
    "imessage.received",
    "imessage.reaction_received",
    "imessage.sent",
    "imessage.delivered",
    "imessage.delivery_failed",
  ],
});

// List, update, remove.
const subs = await inkbox.webhooks.subscriptions.list({ mailboxId: mb.id });
await inkbox.webhooks.subscriptions.update(subs[0].id, { url: "https://new/hook" });
await inkbox.webhooks.subscriptions.delete(subs[0].id);

Available event types:

| Channel | event_type values | |---|---| | Mail | message.received, message.sent, message.forwarded, message.delivered, message.bounced, message.failed | | Phone text | text.received, text.sent, text.delivered, text.delivery_failed, text.delivery_unconfirmed | | iMessage | imessage.received, imessage.reaction_received, imessage.sent, imessage.delivered, imessage.delivery_failed |

Server-side validation: exactly one of mailboxId / phoneNumberId / agentIdentityId must be set; eventTypes must be non-empty and distinct; every event type must belong to the owner's channel (mailbox → message.*, phone number → text.*, agent identity → imessage.*). On create the SDK mirrors the structural checks (XOR owner, non-empty, distinct, no phone.incoming_call) plus the message. / text. / imessage. prefix check, so most shape mistakes surface as Error before the request leaves the client. The server remains authoritative for the exact event-name enum, so a typo with a valid prefix (e.g. message.received_typo) passes the SDK's check and is rejected as 422 by the server. On update the SDK also rejects mixed event families. Owner compatibility remains server-validated because the SDK doesn't know the owner FK from a subscription ID alone.

Conversation context

Opt a subscription into per-class conversation history on received events (message.received, text.received, imessage.received) by passing contextConfig. Each class (email, texts, calls) takes a count mode (last N items, 1..50) or a window mode (last H hours, 1..168); omit a class to leave it unconfigured. Conversation context is not supported for A2A subscriptions.

await inkbox.webhooks.subscriptions.create({
  mailboxId: mb.id,
  url: "https://example.com/hook",
  eventTypes: ["message.received"],
  contextConfig: {
    email: { mode: "count", count: 10 },
    texts: { mode: "window", hours: 24 },
  },
});

// update() is tri-state: omit contextConfig to leave it unchanged, pass an
// object to replace it, or pass null to clear it.
await inkbox.webhooks.subscriptions.update(sub.id, { contextConfig: null });

Received-event payloads then carry an optional payload.data.context keyed by class. Optional fields are omitted when empty (never null) — guard with ?., not === null. A skipped class ships items: [] plus a skipped reason; call transcript entries are either turns or an abridgment marker, discriminated on "marker" in entry:

import type { WebhookContextCallItem, WebhookContextMailItem } from "@inkbox/sdk";

// payload is a MailWebhookPayload / TextWebhookPayload / ... (see below)
const context = payload.data.context;
if (context?.email) {
  if (context.email.skipped) {
    console.log("no email context:", context.email.skipped);
  }
  // Each class's items are that class's item type.
  for (const item of context.email.items as WebhookContextMailItem[]) {
    console.log(item.direction, item.subject);
  }
}
for (const call of (context?.calls?.items ?? []) as WebhookContextCallItem[]) {
  for (const entry of call.transcript) {
    if ("marker" in entry) {
      console.log(`… ${entry.omitted_turns} turns abridged`);
    } else {
      console.log(`${entry.party}: ${entry.text}`);
    }
  }
}

The config types (WebhookContextConfig, WebhookContextClassConfig) and the payload types (WebhookContext, WebhookContextBlock, WebhookTranscriptEntry, …) are exported from @inkbox/sdk.

Delivery auth token

If your endpoint requires its own Authorization header, set an optional bearer token on the subscription. Every delivery (and replay) then carries Authorization: Bearer <token> in addition to the signature headers. Reads return the stored token as authToken (null when unset) along with the boolean hasAuthToken flag; both default to unset on servers that predate the fields.

await inkbox.webhooks.subscriptions.create({
  mailboxId: mb.id,
  url: "https://example.com/hook",
  eventTypes: ["message.received"],
  authToken: "your-endpoint-token",
});

// update() is tri-state: omit authToken to leave it unchanged, pass a
// string to replace it, or pass null to clear it.
await inkbox.webhooks.subscriptions.update(sub.id, { authToken: null });

Incoming-call webhooks (still per-number)

// Route incoming calls to a webhook. The response body controls call routing.
await inkbox.phoneNumbers.update(number.id, {
  incomingCallAction: "webhook",
  incomingCallWebhookUrl: "https://example.com/calls",
});

Wire shapes

Every mail and text payload uses the standard { event_type, timestamp, data } envelope. data.contacts (mail and text) and data.agent_identities are always present, possibly empty. agent_identities mirrors contacts but matches active agent identities in the same org. On mail, each list entry carries a bucket: "from" | "to" | "cc" | "bcc" plus address; receivers should pair to the source field by (bucket, address). data.message.bcc_addresses is populated only on outbound events. Every resolved contact carries active memory text, newest first, in memories; use match.memories ?? [] for replayed payloads that predate contact memories. This is separate from the optional conversation context.

name is null when the contact has no name on file — a contact created automatically from an inbound message has an id and memories before anyone gives it a name. It never falls back to the phone number or email address, so guard it before addressing someone by name.

Phone-text payloads carry several fields for group sends:

  • text_message.recipientsnull on inbound, a one-element list on outbound 1:1, multiple entries on group outbound.
  • text_message.remote_phone_numbernull on group outbound (the per-recip