From inkbox
Provides API-first communication infrastructure for AI agents using the Inkbox TypeScript SDK, enabling email, phone, SMS, iMessage, contacts, notes, and encrypted vault features.
How this skill is triggered — by the user, by Claude, or both
Slash command
/inkbox:inkbox-tsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
API-first communication infrastructure for AI agents — email, phone, encrypted vault, and identities.
API-first communication infrastructure for AI agents — email, phone, encrypted vault, and identities.
npm install @inkbox/sdk
Requires Node.js ≥ 22. ESM module — no context manager needed:
import { Inkbox } from "@inkbox/sdk";
const inkbox = new Inkbox({ apiKey: "ApiKey_..." });
Constructor options: { apiKey: string, baseUrl?: string, timeoutMs?: number }
Inkbox (admin-only client)
├── .createIdentity(handle) → Promise<AgentIdentity>
├── .getIdentity(handle) → Promise<AgentIdentity>
├── .listIdentities() → Promise<AgentIdentitySummary[]>
├── .mailboxes → MailboxesResource
├── .phoneNumbers → PhoneNumbersResource
├── .texts → TextsResource
├── .imessages → IMessagesResource
├── .imessageContactRules → IMessageContactRulesResource
├── .mailContactRules → MailContactRulesResource
├── .phoneContactRules → PhoneContactRulesResource
├── .smsOptIns → SmsOptInsResource
├── .contacts → ContactsResource (.access, .vcards)
├── .notes → NotesResource (.access)
├── .vault → VaultResource
├── .whoami() → Promise<WhoamiResponse>
└── .createSigningKey() → Promise<SigningKey>
AgentIdentity (identity-scoped helper)
├── .mailbox → IdentityMailbox | null
├── .phoneNumber → IdentityPhoneNumber | null
├── .getCredentials() → Promise<Credentials> (requires vault unlocked)
├── .listAccess() → Promise<IdentityAccess[]>
├── .grantAccess(viewerId|null) → Promise<IdentityAccess>
├── .revokeAccess(viewerId) → Promise<void>
├── 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.
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).
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.update({ status: "paused" }); // or "active"
await identity.refresh(); // re-fetch from API, updates cached channels
await identity.delete(); // cascades: mailbox + tunnel + phone-number release
// Identity is created with a mailbox AND tunnel atomically — both are on the response
console.log(identity.emailAddress); // e.g. "[email protected]"
console.log(identity.tunnel?.publicHost); // e.g. "sales-agent.inkboxwire.com"
// Phone numbers are still opt-in
const phone = await identity.provisionPhoneNumber({ type: "toll_free" });
console.log(phone.number); // e.g. "+18005551234"
// 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).
Controls which other agent identities can see an identity in API responses. Humans and admins always see every identity.
const rules = await identity.listAccess(); // IdentityAccess[]
// One wildcard row (viewerIdentityId === null → every active identity sees it),
// explicit per-viewer rows, or [] (no agent can see it).
await identity.grantAccess(viewer.id); // grant one viewer identity
await identity.grantAccess(null); // reset to org-wide wildcard
await identity.revokeAccess(viewer.id); // revoke one viewer (keyed by viewer UUID)
Granting a viewer against an already-wildcard target raises RedundantContactAccessGrantError (409); revoking a non-existent grant raises InkboxAPIError (404).
const sent = await identity.sendEmail({
to: ["[email protected]"],
subject: "Hello",
bodyText: "Hi there!", // plain text (optional)
bodyHtml: "<p>Hi there!</p>", // HTML (optional)
cc: ["[email protected]"], // optional
bcc: ["[email protected]"], // optional
inReplyToMessageId: sent.id, // for threaded replies
attachments: [{ // optional
filename: "report.pdf",
contentType: "application/pdf",
contentBase64: "<base64>",
}],
});
// 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);
// Get full thread (oldest-first)
const thread = await identity.getThread(msg.threadId);
for (const m of thread.messages) {
console.log(`[${m.fromAddress}] ${m.subject}`);
}
Threads carry a folder field: inbox, spam, archive, or blocked (server-assigned, never client-set).
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.
// 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);
// List calls (offset pagination)
const calls = await identity.listCalls({ limit: 10, offset: 0 });
for (const c of calls) {
console.log(c.id, c.direction, c.remotePhoneNumber, c.status);
}
// 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"
}
Outbound SMS limits and gates (current):
429 sender_rate_limited.identity.phoneNumber.smsStatus is SmsStatus.PENDING until ready; sends in this window return 409 sender_sms_pending.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).Customer-managed 10DLC brands/campaigns lift the default per-number cap to the carrier-assigned tier. Toll-free SMS sending is still coming soon.
// Send SMS/MMS from this identity's phone number.
// Returns a queued TextMessage; final delivery state arrives via any
// webhook subscription on the sender's phone number whose eventTypes
// include the text.* lifecycle events.
const sent = await identity.sendText({
to: "+15551234567",
text: "Hello from Inkbox",
});
console.log(sent.id, sent.deliveryStatus); // "queued"
// Group MMS beta: pass an array of recipients plus optional media URLs.
const group = await identity.sendText({
to: ["+15551234567", "+15557654321"],
text: "Hello group",
mediaUrls: ["https://example.com/photo.jpg"],
});
console.log(group.conversationId, group.recipients);
// Reply to an existing conversation by UUID. Do not pass `to` with this form.
const reply = await identity.sendText({
conversationId: group.conversationId,
text: "Following up in the same conversation.",
});
// List text messages (offset pagination)
const texts = await identity.listTexts({ limit: 20, offset: 0 });
for (const t of texts) {
console.log(t.id, t.direction, t.remotePhoneNumber, t.text, t.isRead);
}
// Filter by read state
const unread = await identity.listTexts({ isRead: false });
// Get a single text message
const text = await identity.getText("text-uuid");
console.log(text.type); // "sms" or "mms"
if (text.media) { // MMS media attachments (temporary signed URLs)
for (const m of text.media) {
console.log(m.contentType, m.size, m.url);
}
}
// List one-to-one conversation summaries; opt into groups explicitly.
const convos = await identity.listTextConversations({ limit: 20, includeGroups: true });
for (const c of convos) {
console.log(c.id, c.participants, c.latestHasMedia, c.latestText);
}
// Get messages in a specific conversation by remote number or conversation UUID.
const msgs = await identity.getTextConversation("+15551234567", { limit: 50 });
// Mark a text as read (identity convenience method)
await identity.markTextRead("text-uuid");
// Mark all messages in a conversation as read
const readResult = await identity.markTextConversationRead("+15551234567");
console.log(readResult.updatedCount);
// Admin-only: search, update, delete
const results = await inkbox.texts.search(phone.id, { q: "invoice", limit: 20 });
await inkbox.texts.update(phone.id, "text-uuid", { status: "deleted" });
iMessage works differently from SMS: there is no per-identity iMessage number. Recipients connect to an agent identity through a small shared pool of numbers — they ask the triage line to connect them to @agent_handle, and that creates an assignment between that one recipient and the identity. Everything agent-facing is keyed by conversationId / remoteNumber; the shared local number is never exposed, and there is no cold outreach — you can only message recipients who connected first.
Discover the router (triage) line at runtime — it can change, so never hardcode it:
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):
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):
// Send to a connected recipient, or reply into a conversation by UUID.
const sent = await identity.sendIMessage({ to: "+15551234567", text: "Hello over iMessage" });
const reply = await identity.sendIMessage({
conversationId: sent.conversationId,
text: "With style",
sendStyle: "slam", // IMessageSendStyle: confetti, lasers, slam, ...
});
console.log(sent.service, sent.status); // "imessage", "queued"
// List messages / conversations
const msgs = await identity.listIMessages({ limit: 20, isRead: false });
const convos = await identity.listIMessageConversations({ limit: 20 });
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);
// Who is actively connected to this identity right now (paginated)?
const connections = await identity.listIMessageAssignments({ limit: 20 });
for (const a of connections) {
console.log(a.remoteNumber, a.status, a.createdAt);
}
// Tapback reactions. Sends accept the classic six (love, like, dislike,
// laugh, emphasize, question); inbound can also be "custom" with the
// literal emoji in customEmoji.
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);
}
// Read receipts + typing indicator
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 (not a phone number) because pool numbers are shared infrastructure:
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, { status: "paused" }); // 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.
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).
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");
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 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
}
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 a login secret (secretType inferred from payload shape)
await unlocked.createSecret({
name: "AWS Production",
description: "Production IAM user",
payload: { password: "s3cret", username: "admin", url: "https://aws.amazon.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");
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
| 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-facing credential access — typed, identity-scoped. The vault stays as the admin surface; identity.getCredentials() is the agent runtime surface.
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");
inkbox.vault.unlock() first — throws InkboxError if vault is not unlockedidentity.refresh() to clear the cacheget* throws Error if not found, TypeError if wrong secret typeTOTP secrets are stored inside LoginPayload.totp in the encrypted vault. Codes are generated client-side — no server call needed.
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: "[email protected]",
password: "s3cret",
totp: parseTotpUri("otpauth://totp/GitHub:[email protected]?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);
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);
| 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 |
inkbox.mailboxes)const mailboxes = await inkbox.mailboxes.list();
const mailbox = await inkbox.mailboxes.get("[email protected]");
// 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.
// Switch contact-rule filter mode (admin-only — agent-scoped keys get 403)
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).
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)
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).
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.
inkbox.phoneNumbers)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: "toll_free" });
const local = await inkbox.phoneNumbers.provision({ agentHandle: "my-agent", type: "local", state: "NY" });
await inkbox.phoneNumbers.update(num.id, {
incomingCallAction: "webhook", // "webhook", "auto_accept", or "auto_reject"
incomingCallWebhookUrl: "https://...",
});
await inkbox.phoneNumbers.update(num.id, {
incomingCallAction: "auto_accept",
clientWebsocketUrl: "wss://...",
});
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 is admin-only and returns a change-notice when the value actually changed.
Per-mailbox or per-phone-number allow/block lists, enforced server-side. The active filterMode on the owning resource decides whether the rules are a whitelist or blacklist. Mail matches by exact email or domain; phone matches by exact E.164 number.
import {
MailRuleAction, MailRuleMatchType, PhoneRuleAction, PhoneRuleMatchType,
DuplicateContactRuleError,
} from "@inkbox/sdk";
// Mail rules — scoped to a single mailbox. New rules always start active;
// call `update(..., { status: "paused" })` afterwards to pause one.
const rule = await inkbox.mailContactRules.create(mailbox.emailAddress, {
action: MailRuleAction.ALLOW, // or BLOCK
matchType: MailRuleMatchType.DOMAIN, // or EXACT_EMAIL
matchTarget: "example.com",
});
await inkbox.mailContactRules.list(mailbox.emailAddress);
await inkbox.mailContactRules.get(mailbox.emailAddress, rule.id);
await inkbox.mailContactRules.update(mailbox.emailAddress, rule.id, { status: "paused" }); // admin-only
await inkbox.mailContactRules.delete(mailbox.emailAddress, rule.id); // admin-only
// Admin-only list; optionally narrow to a single mailboxId
const allRules = await inkbox.mailContactRules.listAll({ mailboxId: mailbox.id });
// Duplicate (matchType, matchTarget) on the same mailbox throws 409:
try {
await inkbox.mailContactRules.create(mailbox.emailAddress, {
action: "allow", matchType: "domain", matchTarget: "example.com",
});
} catch (e) {
if (e instanceof DuplicateContactRuleError) {
console.log(e.existingRuleId); // id of the rule that already matched
}
}
// Phone rules — same shape, only matchType: "exact_number" is supported.
await inkbox.phoneContactRules.create(num.id, {
action: PhoneRuleAction.BLOCK,
matchType: PhoneRuleMatchType.EXACT_NUMBER,
matchTarget: "+15551234567",
});
await inkbox.phoneContactRules.list(num.id);
await inkbox.phoneContactRules.listAll({ phoneNumberId: num.id });
Admin-only address book with per-identity access grants and vCard import/export.
import type { CreateContactOptions, ContactEmail, ContactPhone } from "@inkbox/sdk";
import { RedundantContactAccessGrantError } from "@inkbox/sdk";
// CRUD
const contact = await inkbox.contacts.create({
givenName: "Ada",
familyName: "Lovelace",
emails: [{ label: "work", value: "[email protected]" }],
phones: [{ label: "mobile", value: "+15551234567" }],
// accessIdentityIds defaults to "wildcard"; pass [] for admin-only, or
// a list of identity UUIDs for explicit grants.
});
await inkbox.contacts.get(contact.id);
await inkbox.contacts.list({ q: "ada", order: "recent", limit: 50, offset: 0 });
await inkbox.contacts.update(contact.id, { jobTitle: "Analyst" }); // JSON-merge-patch
await inkbox.contacts.delete(contact.id);
// Reverse-lookup — exactly one filter required (else thrown before HTTP)
await inkbox.contacts.lookup({ email: "[email protected]" });
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" });
// Access grants (admin + JWT only; agents can self-revoke)
await inkbox.contacts.access.list(contact.id);
await inkbox.contacts.access.grant(contact.id, { identityId: "agent-uuid" });
await inkbox.contacts.access.grant(contact.id, { wildcard: true }); // every active identity
await inkbox.contacts.access.revoke(contact.id, "agent-uuid");
try {
await inkbox.contacts.access.grant(contact.id, { identityId: "agent-uuid" });
} catch (e) {
if (e instanceof RedundantContactAccessGrantError) {
console.log(e.error, e.detailMessage);
}
}
// 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);
}
const vcf = await inkbox.contacts.vcards.export(contact.id); // vCard 4.0 string
Admin-only free-form notes with per-identity access grants. There is no wildcard for notes — grant identities explicitly.
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");
// 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:
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.)
}
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.
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
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.
Reads + edit:
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 are configured directly on the mailbox or phone number — no separate registration.
import {
verifyWebhook,
MailWebhookPayload, TextWebhookPayload, PhoneIncomingCallWebhookPayload,
} from "@inkbox/sdk";
// Rotate signing key (plaintext returned once — save it)
const key = await inkbox.createSigningKey();
// Verify, then parse + discriminate
const valid = verifyWebhook({
payload: req.body, // Buffer or string
headers: req.headers as Record<string, string>,
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:
message.received, message.sent, message.forwarded, message.delivered, message.bounced, message.failed. Subscribe via inkbox.webhooks.subscriptions.create({ mailboxId, url, eventTypes }).text.received, text.sent, text.delivered, text.delivery_failed, text.delivery_unconfirmed. Subscribe via inkbox.webhooks.subscriptions.create({ phoneNumberId, 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.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.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.Subscription resource: inkbox.webhooks.subscriptions.{list,get,create,update,delete}. Each subscription names exactly one owner (mailbox, phone number, or agent identity), one HTTPS destination URL, and a non-empty subset of the catalog's event types. Multiple subscriptions on the same owner fan out independently (cap: 20 active per owner). The SDK runs structural + prefix validation client-side (exactly-one-FK, non-empty distinct events, no phone.incoming_call, message. / text. / imessage. prefix matching the owner's channel) 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.
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). 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 } matches; data.agent_identities mirrors that for matched agent identities. 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, 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.
import {
InkboxAPIError,
DuplicateContactRuleError,
RedundantContactAccessGrantError,
} 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
}
}
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 a contact-access grant is redundant (e.g. per-identity grant on top of an active wildcard). Exposes .error and .detailMessage.iterEmails() / iterUnreadEmails() return AsyncGenerator<Message> — use for await...oflistCalls() returns Promise<PhoneCall[]> — offset pagination, not a generatorfield: nullnew Inkbox({...}) is all that's requiredasync and return Promises — always await themnpx claudepluginhub inkbox-ai/inkbox --plugin inkboxUse the Inkbox Python SDK for email, phone, SMS, iMessage, contacts, notes, vault, tunnels, and agent identity features in AI agent communication infrastructure.
Send/receive SMS/MMS via Telnyx JavaScript SDK, handle delivery webhooks and opt-outs. For notifications, 2FA, or messaging apps.
Sends transactional emails via the Resend API, manages templates, webhooks, domains, contacts, broadcasts, and automations. Use when working with Resend email infrastructure.