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.
Guides on using the Resend email API for sending transactional emails, managing webhooks, templates, domains, contacts, broadcasts, and tracking delivery events. Includes essential gotchas for idempotency keys and webhook verification.