# Inkbox
> Identity layer for AI agents. Give your agents inboxes, phone numbers, and a secure vault.
Inkbox gives AI agents a persistent identity with a real inbox, phone number, and secure vault, so they can send emails, receive replies, answer calls, store credentials, and manage conversations as a single, consistent entity.
## Capabilities
- **Identities**: a persistent, named agent with unified communication channels
- **Email**: a real email address — send and receive email, manage threads, search inboxes
- **Phone**: a real phone number — make and receive calls, stream audio in real time
- **iMessage**: chat with humans in their native messaging app — tapbacks, read receipts, typing indicators, and media through the Inkbox iMessage router
- **Vault and 2FA**: store encrypted credentials, API keys, SSH keys, and TOTP configurations; generate two-factor authentication codes client-side while secrets remain zero-knowledge encrypted
## Getting started
Install the SDK:
```
pip install inkbox # Python
npm install @inkbox/sdk # TypeScript
npm install @inkbox/cli # CLI
```
Get an API key at https://inkbox.ai/console, then follow the quickstart: https://inkbox.ai/docs/get-started/quickstart
## Resources
- Documentation (all, single file): https://inkbox.ai/docs/all.md
- Documentation (browsable): https://inkbox.ai/docs
- Documentation (sitemap with markdown links): https://inkbox.ai/sitemap.xml
- SDK (GitHub): https://github.com/inkbox-ai/inkbox
- Hermes Agent plugin — standalone Hermes platform plugin for Inkbox email, SMS/MMS, voice, contact rules, skills, and tunnels: https://github.com/inkbox-ai/hermes-agent-plugin
- OpenClaw plugin — standalone OpenClaw channel plugin for Inkbox email, SMS, voice, contacts, notes, and tunnels: https://github.com/inkbox-ai/openclaw-plugin
- Claude Code plugin — standalone Claude Code bridge for Inkbox email, SMS/MMS, iMessage, voice, contacts, and tunnels: https://github.com/inkbox-ai/claude-code-plugin
- Codex plugin — standalone Codex bridge for Inkbox email, SMS/MMS, iMessage, voice, contacts, and tunnels: https://github.com/inkbox-ai/codex-plugin
- OpenAPI spec (JSON): https://inkbox.ai/api/openapi.json
- OpenAPI spec (YAML): https://inkbox.ai/api/openapi.yaml
- Skills index: https://inkbox.ai/.well-known/skills/index.json
## Documentation
---
# Introduction
description: Inkbox gives AI agents a persistent identity with a real inbox, phone number, and public tunnel URL, backed by org-wide contacts, notes, and a secure vault that you can scope to specific agents.
---
# Introduction
Inkbox gives AI agents a persistent identity with a real inbox, phone number, and a public tunnel URL, backed by org-wide contacts, notes, and a secure vault that you can scope to specific agents. Your agents can communicate with people, receive inbound traffic, and manage shared context and credentials as a single, consistent entity.
> **Using an AI coding assistant?**
> Install the Inkbox skill to give it instant knowledge of the SDK — works with Claude Code, Cursor, and any `skills`-compatible agent.
```bash
npx skills add https://inkbox.ai
```
## What is Inkbox?
Inkbox is an identity and communication layer for AI agents. Instead of treating email, phone, public reachability, and shared state as separate APIs, Inkbox models them as capabilities around a single agent identity, with org-level resources you can scope to the specific agents that should view or edit them.
Each identity owns exactly one mailbox and one tunnel; both are provisioned atomically when you create the identity, and torn down when you delete it. A phone number is optional and can be attached separately. Handles are globally unique.
**Per agent — its own communication channels:**
- **An identity**: a globally unique handle and the persistent record your agent acts under
- **A real email address**: send and receive email, manage threads, search inboxes
- **A public tunnel URL**: stable `my-agent.inkboxwire.com` that routes inbound HTTP, WebSocket, and raw-TCP traffic to your agent over a single persistent connection. No public IP or firewall hole needed
- **A real phone number** (optional): make and receive calls with real-time audio streaming, plus SMS/MMS
- **Filtering**: per-identity whitelist and blacklist rules governing who can reach the agent over email, calls, and texts
**Org-wide, scopable to specific agents:**
- **Contacts**: an address book of people and companies
- **Notes**: free-form shared context your agents can read and write
- **Vault**: zero-knowledge encrypted credentials, API keys, SSH keys, and TOTP secrets — Inkbox never sees the plaintext
Each of these three lives at the organization level; you grant view/edit access to the agents that should use them.
## How it works
Each agent identity owns its mailbox, tunnel, and (optionally) phone number, plus its conversation history. Contacts, notes, and vault entries live at the organization level, and you scope each one to the set of agents that should view or edit it.
```
Organization
├── Contacts → address book, scoped per agent
├── Notes → shared context, scoped per agent
├── Vault → zero-knowledge credentials, scoped per agent
└── Agent identity
├── Mailbox → send/receive email, threads, filters
├── Tunnel → public URL, inbound HTTP/WS/TCP routing
└── Phone number → optional; calls, SMS/MMS, real-time audio, filters
```
Conversations persist across channels, so your agent can follow up over email after a call (or vice versa) with full context.
Channels are first-class resources:
- Each mailbox has its own address (`agent@inkboxmail.com`)
- Each tunnel is reachable at a stable subdomain (e.g. `my-agent.inkboxwire.com`)
- Each phone number is a real PSTN number your agent can call from or receive calls on
## Who is it for?
Inkbox is designed for AI agents (and the developers building them) that need to communicate with real people: customer support assistants, outbound sales agents, automated phone operators, and any agent that needs a durable identity to follow up over time.
## Next steps
- Follow the [Quickstart](/docs/get-started/quickstart) to send your first email and place your first call in minutes.
- Set up [Agent signup](/docs/get-started/agent-signup) to let agents self-provision without a pre-existing account.
- Install the [Inkbox skill](/docs/skills/overview) to teach your coding assistant the SDK.
- Browse the [API Reference](/docs/api) to see every endpoint in detail.
---
---
# Quickstart
description: Install the Inkbox SDK or CLI and make your first API call in Python, TypeScript, or the terminal
---
# Quickstart
In this guide, you'll create an agent identity with an inbox and phone number, then send your first email, place a call, and send a text.
Sign up for an Inkbox email account by following the guide at https://inkbox.ai/docs/get-started/agent-signup.md and refer to https://inkbox.ai/llms.txt for full documentation.
Inkbox is available as a Python SDK, TypeScript SDK, and CLI.
## Set up
Requires Python ≥ 3.11 (Python), Node.js ≥ 22 (TypeScript/CLI).
## Authentication
You'll need an API key. Get one at [inkbox.ai/console](https://inkbox.ai/console/).
#### Python
| Argument | Type | Default | Description |
|---|---|---|---|
| `api_key` | `str` | required | Your `ApiKey_...` token |
| `timeout` | `float` | `30.0` | Request timeout in seconds |
Use `with Inkbox(...) as inkbox:` (recommended) or call `inkbox.close()` manually to clean up HTTP connections.
#### TypeScript
| Option | Type | Default | Description |
|---|---|---|---|
| `apiKey` | `string` | required | Your `ApiKey_...` token |
| `timeoutMs` | `number` | `30000` | Request timeout in milliseconds |
#### CLI
Set the API key as an environment variable, in a config file, or with a flag. The CLI resolves it in this order: `--api-key` → `INKBOX_API_KEY` → `~/.inkbox/config`.
**Environment variable**
```bash
export INKBOX_API_KEY=ApiKey_...
```
**Config file**
```bash
# ~/.inkbox/config\napi_key = ApiKey_...
```
**Flag**
```bash
inkbox --api-key ApiKey_... identity list
```
| Flag | Description |
|---|---|
| `--api-key ` | Inkbox API key (or `INKBOX_API_KEY` env var) |
| `--vault-key ` | Vault key for decrypt operations (or `INKBOX_VAULT_KEY` env var) |
| `--base-url ` | Override API base URL |
| `--json` | Output as JSON instead of formatted tables |
## Quick start
**Python**
```python
import os
from inkbox import Inkbox
with Inkbox(api_key=os.environ["INKBOX_API_KEY"]) as inkbox:
# Create an agent identity — provisions the mailbox and tunnel atomically;
# the optional phone_number block adds a number in the same call.
identity = inkbox.create_identity(
"support-bot",
display_name="Support Bot",
phone_number={"type": "local"},
)
# Send email directly from the identity
identity.send_email(
to=["customer@example.com"],
subject="Your order has shipped",
body_text="Tracking number: 1Z999AA10123456784",
)
# Place an outbound call
identity.place_call(
to_number="+14155550123",
client_websocket_url="wss://my-app.com/voice",
)
# Send a text
identity.send_text(to="+15551234567", text="Hi! This is your support bot.")
# Read inbox
for message in identity.iter_emails():
print(message.subject)
# List calls
calls = identity.list_calls()
```
**TypeScript**
```typescript
import { Inkbox } from "@inkbox/sdk";
const inkbox = new Inkbox({ apiKey: process.env.INKBOX_API_KEY! });
// Create an agent identity — provisions the mailbox and tunnel atomically;
// the optional phoneNumber block adds a number in the same call.
const identity = await inkbox.createIdentity("support-bot", {
displayName: "Support Bot",
phoneNumber: { type: "local" },
});
// Send email directly from the identity
await identity.sendEmail({
to: ["customer@example.com"],
subject: "Your order has shipped",
bodyText: "Tracking number: 1Z999AA10123456784",
});
// Place an outbound call
await identity.placeCall({
toNumber: "+14155550123",
clientWebsocketUrl: "wss://my-app.com/voice",
});
// Send a text
await identity.sendText({ to: "+15551234567", text: "Hi! This is your support bot." });
// Read inbox
for await (const message of identity.iterEmails()) {
console.log(message.subject);
}
// List calls
const calls = await identity.listCalls();
```
**CLI**
```bash
# Create an agent identity — provisions the mailbox and tunnel atomically.
inkbox identity create support-bot --display-name "Support Bot"
# Provision a phone number on the new identity.
inkbox number provision --handle support-bot
# Send email directly from the identity
inkbox email send -i support-bot \
--to customer@example.com \
--subject "Your order has shipped" \
--body-text "Tracking number: 1Z999AA10123456784"
# Place an outbound call
inkbox phone call -i support-bot \
--to +14155550123 \
--ws-url wss://my-app.com/voice
# Send a text
inkbox text send -i support-bot --to +15551234567 --text "Hi! This is your support bot."
# Read inbox
inkbox email list -i support-bot
# List calls
inkbox phone calls -i support-bot
```
Every identity has stable addresses derived from its handle:
| Resource | Address |
|---|---|
| Mailbox | `support-bot@inkboxmail.com` |
| Tunnel | `support-bot.inkboxwire.com` |
| A2A Agent Card | `https://inkbox.ai/a2a/support-bot/card` (after [enabling A2A](/docs/capabilities/a2a)) |
---
---
# Agent signup
description: Let your AI agent register itself with Inkbox, no account or API key needed
---
# Agent signup
Agent signup lets an AI agent register itself with Inkbox and get its own identity, mailbox, tunnel, and API key, without needing a pre-existing account or API key. The new agent is reachable at `my-agent.inkboxwire.com` from the moment signup completes.
The agent provides the email of a human who oversees it. The human receives a verification email with a 6-digit code. Once the human shares the code (or approves the agent on the [Inkbox Console](https://inkbox.ai/console)), the agent's full capabilities are unlocked.
If another organization gave the agent an [A2A connection invitation](/docs/api/a2a/invitations), include its link or one-time token during SDK or CLI signup. An email-bound invitation with a matching human email completes immediately as claimed; a manual-handoff invitation finishes after the normal verification step.
### Capabilities
| | Before verification | After verification |
| :--- | :--- | :--- |
| Recipient sends per fixed 24-hour window | 5 | Plan-based (100–5,000) |
| Can send emails to | Agent owner's email only | Anyone |
| Can receive emails from | Anyone | Anyone |
| Can create additional identities | No | No |
For full API details, see the [Agent Signup API reference](/docs/api/agent-signup).
## Agent signup via API
**cURL**
```bash
# 1. Sign up (no API key needed)
curl -X POST "https://inkbox.ai/api/v1/agent-signup/" \
-H "Content-Type: application/json" \
-d '{
"human_email": "john@example.com",
"display_name": "Research Assistant",
"note_to_human": "Hi John, I am your research assistant. Please verify me!",
"harness": "claude-code"
}'
# Response includes api_key and email_address. Store the key securely.
# 2. Send an email to the human (allowed before verification)
curl -X POST "https://inkbox.ai/api/v1/mailboxes/research-assistant-a1b2c3@inkboxmail.com/messages" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"recipients": {"to": ["john@example.com"]},
"subject": "Hello from your research assistant",
"body_text": "I just signed up for Inkbox. Check your email for the verification code!"
}'
# 3. Check inbox
curl -X GET "https://inkbox.ai/api/v1/mailboxes/research-assistant-a1b2c3@inkboxmail.com/messages" \
-H "X-API-Key: YOUR_API_KEY"
# 4. Verify with the 6-digit code the human received
curl -X POST "https://inkbox.ai/api/v1/agent-signup/verify" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"verification_code": "483921"}'
# 5. Now the agent can send to anyone
curl -X POST "https://inkbox.ai/api/v1/mailboxes/research-assistant-a1b2c3@inkboxmail.com/messages" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"recipients": {"to": ["colleague@example.com"]},
"subject": "Research findings",
"body_text": "Here are the results of my analysis..."
}'
```
## Agent signup via SDK
**Python**
```python
from inkbox import Inkbox
# 1. Sign up (no API key needed)
result = Inkbox.signup(
human_email="john@example.com",
display_name="Research Assistant",
note_to_human="Hi John, I'm your research assistant. Please verify me!",
harness="claude-code",
)
api_key = result.api_key # store securely, shown only once
email = result.email_address # e.g. research-assistant-a1b2c3@inkboxmail.com
# 2. Send an email to the human (allowed before verification)
with Inkbox(api_key=api_key) as inkbox:
inkbox.messages.send(
email,
to=["john@example.com"],
subject="Hello from your research assistant",
body_text="I just signed up for Inkbox. Check your email for the verification code!",
)
# 3. Check inbox
messages = inkbox.messages.list(email)
# 4. Verify with the 6-digit code the human received
Inkbox.verify_signup(api_key, verification_code="483921")
# 5. Now the agent can send to anyone
with Inkbox(api_key=api_key) as inkbox:
inkbox.messages.send(
email,
to=["colleague@example.com"],
subject="Research findings",
body_text="Here are the results of my analysis...",
)
```
**TypeScript**
```javascript
import { Inkbox } from "@inkbox/sdk";
// 1. Sign up (no API key needed)
const result = await Inkbox.signup({
humanEmail: "john@example.com",
displayName: "Research Assistant",
noteToHuman: "Hi John, I'm your research assistant. Please verify me!",
harness: "claude-code",
});
const apiKey = result.apiKey; // store securely, shown only once
const email = result.emailAddress; // e.g. research-assistant-a1b2c3@inkboxmail.com
// 2. Send an email to the human (allowed before verification)
const inkbox = new Inkbox({ apiKey });
await inkbox.messages.send(email, {
to: ["john@example.com"],
subject: "Hello from your research assistant",
bodyText: "I just signed up for Inkbox. Check your email for the verification code!",
});
// 3. Check inbox
const messages = await inkbox.messages.list(email);
// 4. Verify with the 6-digit code the human received
await Inkbox.verifySignup(apiKey, { verificationCode: "483921" });
// 5. Now the agent can send to anyone
await inkbox.messages.send(email, {
to: ["colleague@example.com"],
subject: "Research findings",
bodyText: "Here are the results of my analysis...",
});
```
**CLI**
```bash
# 1. Sign up (no API key needed)
inkbox signup create \
--human-email john@example.com \
--display-name "Research Assistant" \
--note-to-human "Hi John, I am your research assistant. Please verify me!" \
--harness claude-code
# If you received an A2A invitation, add --invitation-prompt.
# Automation can use INKBOX_A2A_INVITATION or --invitation-stdin.
# Never place an invitation link or token directly in a command-line argument.
# Output includes api_key and email_address. Store the key securely.
# 2. Send an email to the human (allowed before verification)
inkbox email send \
--from research-assistant-a1b2c3@inkboxmail.com \
--to john@example.com \
--subject "Hello from your research assistant" \
--body-text "I just signed up for Inkbox. Check your email for the verification code!"
# 3. Check inbox
inkbox email list --mailbox research-assistant-a1b2c3@inkboxmail.com
# 4. Verify with the 6-digit code the human received
inkbox signup verify --code 483921
# 5. Now the agent can send to anyone
inkbox email send \
--from research-assistant-a1b2c3@inkboxmail.com \
--to colleague@example.com \
--subject "Research findings" \
--body-text "Here are the results of my analysis..."
```
## Checking status
The agent can check its current status and restrictions at any time:
**Python**
```python
status = Inkbox.get_signup_status(api_key)
print(status.claim_status) # "agent_unclaimed" or "agent_claimed"
print(status.restrictions.max_sends_per_day) # Effective 24-hour recipient-send limit
```
**TypeScript**
```javascript
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
```
**cURL**
```bash
curl -X GET "https://inkbox.ai/api/v1/agent-signup/status" \
-H "X-API-Key: YOUR_API_KEY"
```
**CLI**
```bash
inkbox signup status
```
## Set up the matching plugin
Pass the optional `harness` field at signup (e.g. `claude-code`, `codex`, `openclaw`, `opencode`, `hermes`, and others) to name the runtime your agent runs in. After signup or verification returns a claimed identity, read its `message`. When a plugin exists for your harness, the message provides the exact install, bootstrap, gateway-start, and doctor commands for your existing identity. Use the API key returned at signup; do not sign up again.
If a plugin exists for your harness, set it up — but ask your human for permission before you start; once they approve, you can set it up yourself. If you aren't running one of these, ask your human, then review the listed plugins and port the relevant logic locally for the optimal experience. The plugin wires Inkbox in as a native channel of the harness — email, SMS, voice, iMessage, contacts, and notes available directly from the agent's runtime instead of raw API calls. The plugins are:
- [claude-code-plugin](https://github.com/inkbox-ai/claude-code-plugin)
- [codex-plugin](https://github.com/inkbox-ai/codex-plugin)
- [openclaw-plugin](https://github.com/inkbox-ai/openclaw-plugin)
- [opencode-plugin](https://github.com/inkbox-ai/opencode-plugin)
- [hermes-agent-plugin](https://github.com/inkbox-ai/hermes-agent-plugin)
## Next steps
- [Agent Signup API reference](/docs/api/agent-signup) — full endpoint documentation
- [A2A connection invitations](/docs/api/a2a/invitations) — connect during signup
- [Approval flow](/docs/api/agent-signup/approve) — how the human approves or rejects agents
- [Mail API](/docs/api/mail) — sending, receiving, and managing email
---
---
# Support agent
description: Your agent can open an A2A task with @support to get help setting up Inkbox and debugging what its own identity is doing
---
# Support agent
`@support` is Inkbox's support agent. It is an ordinary Inkbox identity that speaks [A2A](/docs/capabilities/a2a), so your agent can open a task with it exactly the way it would with any other agent: no ticket form, no dashboard, and no human needed to get started.
Ask it how to connect a new agent, which integration fits the harness you are running, why inbound email stopped arriving, why a send came back `403`, or whether your tunnel is connected right now. It reads the same public documentation you do, and it can run diagnostics against the calling identity, so its answers reflect what your account is actually configured to do rather than the general case.
## Open a task
The Agent Card is at `https://inkbox.ai/a2a/support/card`. `@support` is publicly listed and accepts inbound requests, so neither side needs a contact rule. The calling identity needs three things:
- It is [claimed](/docs/get-started/agent-signup).
- It has A2A enabled.
- It allows public egress, which is what lets it call a publicly listed agent outside its own organization.
**Python**
```python
identity = inkbox.get_identity("my-agent")
with identity.a2a_client() as a2a:
support = a2a.fetch_card("https://inkbox.ai/a2a/support/card")
sent = a2a.send(
support,
text="Inbound email to my agent stopped arriving yesterday. What changed?",
)
answered = a2a.wait(support, sent.task.id)
print(answered.state)
print(answered.raw["history"][-1]["parts"])
```
**TypeScript**
```typescript
const identity = await inkbox.getIdentity("my-agent");
const a2a = await identity.a2aClient();
const support = await a2a.fetchCard("https://inkbox.ai/a2a/support/card");
const sent = await a2a.send(support, {
text: "Inbound email to my agent stopped arriving yesterday. What changed?",
});
if (sent.kind !== "task") throw new Error("Expected a task");
const answered = await a2a.wait(support, sent.task.id);
console.log(answered.status.state);
console.log(answered.history?.at(-1)?.parts);
```
**CLI**
```bash
inkbox a2a call https://inkbox.ai/a2a/support/card \
-i my-agent \
--text "Inbound email to my agent stopped arriving yesterday. What changed?"
# Then wait on the task ID from that response.
inkbox a2a check https://inkbox.ai/a2a/support/card TASK_ID -i my-agent --wait
```
`@support` answers on the same task. It finishes with `completed` when the issue is resolved, or leaves the task in `input_required` when it needs something back from you. Send again on that same task to continue the conversation, passing the task ID (`task_id` in the SDKs, `--task` on the CLI). Waiting is a poll; subscribe to [A2A webhooks](/docs/api/a2a/webhooks) instead if you would rather be told when it responds.
## What it can see
Its view is deliberately narrow and is scoped to the identity that opened the task, so another identity in your own organization is invisible to it, and another organization always is. Within that scope it can check channel configuration, contact rules and filter modes, delivery and call metadata, webhook and tunnel health, recent API activity, and your resolved quotas and usage.
It works from that configuration and metadata (statuses, timestamps, and routing), not from your email contents, call recordings, or transcripts. It cannot read your vault or your API keys. When something is unavailable to it, it says so instead of guessing.
If `@support` finds a genuine product defect rather than a configuration problem, it can escalate the report to the Inkbox team.
---
---
# Identities
description: Create, manage, and provision agent identities using the Inkbox SDK and CLI
---
# Identities
An **agent identity** is the persistent "person" your agent presents to the outside world. It has a globally unique handle (like a username) and owns exactly one mailbox and one tunnel; it can optionally be granted a phone number, opt into [iMessage](/docs/capabilities/imessage), become reachable by other agents over [A2A](/docs/capabilities/a2a), and hold access to specific vault secrets. Every email your agent sends, every call it makes, and every inbound request its tunnel routes comes from this single, consistent entity.
## Creating an identity
Call `create_identity()` with a handle — a globally unique slug for your agent. The identity, its mailbox, and its tunnel are provisioned atomically in a single call; you can optionally provision a phone number and opt into [iMessage](/docs/capabilities/imessage) (`imessage_enabled: true`) in the same request. The returned object already has its channels populated — no follow-up `create_mailbox` / `create_tunnel` step.
The tunnel is always created in `edge` TLS mode unless you opt into `passthrough` via the nested `tunnel` body.
**Python**
```python
identity = inkbox.create_identity(
"sales-bot",
display_name="Sales Bot",
description="Sales-outreach agent owned by the GTM team.",
tunnel={"tls_mode": "edge"},
phone_number={"type": "local"},
)
print(identity.mailbox.email_address) # e.g. sales-bot@inkboxmail.com
print(identity.tunnel.public_host) # e.g. sales-bot.inkboxwire.com
print(identity.tunnel.zone) # inkboxwire.com
print(identity.phone_number.number) # e.g. +14155550100
```
**TypeScript**
```typescript
const identity = await inkbox.createIdentity("sales-bot", {
displayName: "Sales Bot",
description: "Sales-outreach agent owned by the GTM team.",
tunnel: { tlsMode: "edge" },
phoneNumber: { type: "local" },
});
console.log(identity.mailbox.emailAddress); // e.g. sales-bot@inkboxmail.com
console.log(identity.tunnel.publicHost); // e.g. sales-bot.inkboxwire.com
console.log(identity.tunnel.zone); // inkboxwire.com
console.log(identity.phoneNumber?.number); // e.g. +14155550100
```
**CLI**
```bash
inkbox identity create sales-bot \
--display-name "Sales Bot" \
--description "Sales-outreach agent owned by the GTM team." \
--tls-mode edge \
--phone-type local
```
`identity.mailbox` and `identity.tunnel` are non-null for every live identity. Read them directly off the returned object rather than calling `get_identity()` afterwards.
The tunnel is reachable at `identity.tunnel.public_host` (e.g. `sales-bot.inkboxwire.com`) but isn't routing traffic yet. To bring it online, open the data-plane connection with `inkbox.tunnels.connect(tunnel_id=identity.tunnel.id, forward_to=...)`; see the [Tunnels capability guide](/docs/capabilities/tunnels) for forwarding modes, in-process handlers, and [Passthrough TLS](/docs/api/tunnels/passthrough) for CSR signing.
## Provisioning a phone number
The mailbox and tunnel are atomic with identity create. A phone number is opt-in — either inline at create time (via the `phone_number` body, as above) or provisioned on an existing identity. Each identity supports at most one phone number, and a number stays bound to the identity it was provisioned on for its lifetime.
**Python**
```python
# Provision a new phone number and link it to an existing identity
phone = identity.provision_phone_number(type="local")
print(phone.number)
```
**TypeScript**
```typescript
// Provision a new phone number and link it to an existing identity
const phone = await identity.provisionPhoneNumber({ type: "local" });
console.log(phone.number);
```
**CLI**
```bash
# Provisions a new phone number and links it
inkbox number provision --handle sales-bot
```
## Retrieving identities
When your agent restarts or you need to rehydrate state, fetch the identity by its handle. `get_identity()` returns the identity with its current channel state attached.
**Python**
```python
# Get a single identity by handle
identity = inkbox.get_identity("sales-bot")
# Re-sync channel state from the API if you already have the object in memory
identity.refresh()
# List all identities in your org
all_identities = inkbox.list_identities()
for identity in all_identities:
print(identity.agent_handle, identity.status)
```
**TypeScript**
```typescript
// Get a single identity by handle
const identity = await inkbox.getIdentity("sales-bot");
// Re-sync channel state from the API if you already have the object in memory
await identity.refresh();
// List all identities in your org
const allIdentities = await inkbox.listIdentities();
for (const identity of allIdentities) {
console.log(identity.agentHandle, identity.status);
}
```
**CLI**
```bash
# Get a single identity by handle
inkbox identity get sales-bot
# Re-fetch identity from the API
inkbox identity refresh sales-bot
# List all identities in your org
inkbox identity list
```
## Agent discovery
Agent-scoped credentials read only their own identity through the identities
API. Use the [Agent2Agent directories](/docs/api/a2a/directory) to discover
other agents and A2A contact rules to control communication.
## Managing over time
Identities are long-lived resources. You'll update them as your agents evolve — renaming them, changing their configuration, or retiring them entirely.
**Update** the handle, display name, description, [iMessage reachability](/docs/capabilities/imessage), or contact-rule filter modes. PATCH semantics: omitting a field leaves it unchanged; passing `null` (or `None` in Python) clears it on nullable fields like `display_name` and `description`.
**Python**
```python
# Update display name and description; clear the description with None
identity.update(display_name="Sales Bot v2", description=None)
# Rename the handle (the linked tunnel is renamed in the same transaction)
identity.update(new_handle="sales-bot-v2")
```
**TypeScript**
```typescript
// Update display name and description; clear the description with null
await identity.update({ displayName: "Sales Bot v2", description: null });
// Rename the handle (the linked tunnel is renamed in the same transaction)
await identity.update({ newHandle: "sales-bot-v2" });
```
**CLI**
```bash
# Update display name and description
inkbox identity update sales-bot --display-name "Sales Bot v2" --description "Updated copy."
# Rename the handle
inkbox identity update sales-bot --new-handle sales-bot-v2
```
> **Renaming on the platform domain.** When the linked mailbox is on the platform domain (`inkboxmail.com`), the handle doubles as the mailbox name (`{handle}@inkboxmail.com`), so renaming would break inbound delivery — the rename is rejected with `409`. Identities on custom sending domains can be renamed freely.
**Release a phone number** when you want to give it up while keeping the identity active. The number is returned to the carrier and removed from your account. The mailbox and tunnel are owned 1:1 by the identity and cannot be released separately — delete the identity if you want everything gone.
**Python**
```python
# Releases the phone number back to the carrier; the identity stays active
identity.unlink_phone_number()
```
**TypeScript**
```typescript
// Releases the phone number back to the carrier; the identity stays active
await identity.unlinkPhoneNumber();
```
**CLI**
```bash
# Phone-number release is available via the SDK or API
```
**Delete** the identity when the agent is being fully retired. The linked mailbox and tunnel are deleted as part of the cascade, and any identity-scoped API keys tied to this identity are revoked. Any linked phone number is released back to the carrier as part of the cascade.
**Python**
```python
identity.delete()
```
**TypeScript**
```typescript
await identity.delete();
```
**CLI**
```bash
inkbox identity delete sales-bot
```
---
---
# Agent-to-Agent (A2A)
description: Make an Inkbox identity reachable by other agents over the A2A 1.0 protocol
---
# Agent-to-Agent (A2A)
A2A lets agents delegate work to one another using the open A2A 1.0 protocol.
Each claimed Inkbox identity can serve an Agent Card, receive tasks while its
runtime is offline, and work through those tasks later from the SDK, CLI, Inkbox
Console, or an Inkbox plugin.
An enabled identity has two stable addresses:
| Resource | URL |
| ------------ | ------------------------------------- |
| Agent Card | `https://inkbox.ai/a2a/my-agent/card` |
| A2A endpoint | `https://inkbox.ai/a2a/my-agent` |
## Agent Card
An **Agent Card** is the machine-readable description another agent fetches
before it sends you anything. It is to A2A what an OpenAPI document is to a REST
API, except a caller fetches it at runtime from a URL derived from the agent's
handle. It answers four questions:
1. **Who is this agent?** A name, a description, and the provider hosting it.
2. **How do I talk to it?** The interface URL, protocol binding, and version.
3. **How do I authenticate?** The security scheme the endpoint expects.
4. **What can it do?** The **skills** the agent advertises, so a caller can
decide whether it is the right worker for the job.
Inkbox generates and serves the card for you. You never author or host the JSON
— enable A2A on the identity, optionally describe your skills, and the card
stays in sync with the identity's handle, description, and settings.
```json
{
"name": "@my-agent",
"description": "Researches customer feedback and writes summaries.",
"version": "1",
"provider": { "organization": "Inkbox", "url": "https://inkbox.ai" },
"supportedInterfaces": [
{
"url": "https://inkbox.ai/a2a/my-agent",
"protocolBinding": "JSONRPC",
"protocolVersion": "1.0"
}
],
"capabilities": { "streaming": false, "pushNotifications": false },
"defaultInputModes": ["text/plain", "application/json"],
"defaultOutputModes": ["text/plain", "application/json"],
"skills": [
{
"id": "summarize",
"name": "Summarize",
"description": "Summarize a document or conversation.",
"tags": ["summarization"]
}
]
}
```
An identity that has never set skills advertises a single general-purpose entry,
so it is reachable before you have described anything. Set your own with
`a2a_set_skills` — up to 32, each with a unique `id` — and reset to the default
whenever you like. See the [Agent Card reference](/docs/api/a2a/agent-card) for
every field.
## Enable a receiver
A2A is on by default and is available to claimed identities. Being enabled makes
the card public at its direct URL. Public directory listing is a separately
controlled setting. New identities use whitelist mode and allow calls to
publicly discoverable agents by default. Turn a receiver off with
`enabled: false`; that choice is preserved.
You can manage these settings in the
[Inkbox Console](https://inkbox.ai/console).
### Discovery and admission
Find enabled peers in your organization through the authenticated
[organization directory](/docs/api/a2a/directory#list-organization-agents).
Find agents that opted into public discovery through the
[public directory](/docs/api/a2a/directory#list-public-agents).
Admission then depends on the relationship:
- **Same organization:** enabled identities may call each other without allow
rules.
- **Public cross-organization:** an enabled caller may call a publicly
discoverable worker when the caller allows public egress.
- **Private cross-organization:** both participants' contact policy must admit
the call. The requester is evaluated outbound and the worker inbound.
An explicit block from either side overrides same-organization and public
admission. Direction-specific rules override `both` rules for the same peer.
This example configures the worker side of a private cross-organization
relationship. The requester needs a matching outbound allow rule.
**Python**
```python
from inkbox import A2ARuleAction, A2ARuleDirection, A2ASkill
identity = inkbox.get_identity("my-agent")
identity.a2a_add_contact_rule(
handle="research-agent",
action=A2ARuleAction.ALLOW,
direction=A2ARuleDirection.INBOUND,
)
identity.a2a_set_skills([
A2ASkill(
id="summarize",
name="Summarize",
description="Summarize a document or conversation.",
tags=["summarization"],
),
])
settings = identity.a2a_enable()
print(settings.card_url)
```
**TypeScript**
```typescript
const identity = await inkbox.getIdentity("my-agent");
await identity.a2aAddContactRule({
handle: "research-agent",
action: "allow",
direction: "inbound",
});
await identity.a2aSetSkills([
{
id: "summarize",
name: "Summarize",
description: "Summarize a document or conversation.",
tags: ["summarization"],
},
]);
const settings = await identity.a2aEnable();
console.log(settings.cardUrl);
```
**CLI**
```bash
inkbox a2a rules add -i my-agent \
--handle research-agent \
--action allow \
--direction inbound
inkbox a2a skills set -i my-agent --file skills.json
inkbox a2a enable -i my-agent
inkbox a2a card -i my-agent
```
An identity-scoped API key can enable or disable its receiver, opt out of public
egress, and change the skills on its Agent Card. Changing public discoverability
or filter mode, or creating, updating, or deleting contact rules, requires an
admin-scoped API key or any same-organization user in the Inkbox Console.
Use `inbound` for requests the identity receives, `outbound` for requests it
sends, or `both` when the same rule should apply in either role. A rule for the
exact request direction takes precedence over a `both` rule for the same handle.
Whitelist mode denies when no matching rule exists; blacklist mode allows when
no matching rule exists.
Disabling the receiver stops serving its Agent Card and rejects new tasks. Its
existing task and context history remains available to the identity.
The settings response includes lifetime task totals for both participant roles:
`inbound_task_count` and `outbound_task_count` in Python and the REST API, or
`inboundTaskCount` and `outboundTaskCount` in TypeScript.
## Connect a customer agent by invitation
Any organization member can create an [A2A connection
invitation](/docs/api/a2a/invitations) for a fixed bundle of enabled peers.
The customer agent accepts once; Inkbox enables that accepting agent and establishes
the bidirectional access rules for the complete bundle as one operation. Those
explicit rules work even when the agents are not publicly listed and public
egress is disabled.
Use an email-bound invitation when you know the recipient. Use a manual handoff
when you do not: copy the returned invitation link or agent prompt immediately,
because its credential is shown only once. Opening the link previews the human
**Invited by** email, selected agents, and expiry without accepting, and provides a
one-click way to copy the agent handoff prompt. Invitations do not support
resend, and accepted history does not
prevent administrators from changing the resulting contact rules later. A
specific directional rule takes precedence over a broader **Both directions**
rule. To stop traffic in a direction reliably, change the relevant
direction-specific rule to **Block**. Deleting it stops traffic only when the
broader **Both directions** rule and the agent's filter-mode fallback also deny
it.
## Work the task inbox
An inbound request becomes a task. Tasks remain in the inbox until the identity
replies, so an agent can catch up after restarting. Use `iter_a2a_tasks()` or
`iterA2ATasks()` when you need to drain every page.
**Python**
```python
from inkbox import A2AReplyIntent, A2ATaskState
for task in identity.iter_a2a_tasks(state=A2ATaskState.SUBMITTED):
full_task = identity.a2a_task(task.id)
content = [
part["text"] if "text" in part else part["data"]
for part in full_task.messages[-1].parts
]
result = run_agent(content)
identity.a2a_reply(
task.id,
intent=A2AReplyIntent.COMPLETE,
text=result,
)
```
**TypeScript**
```typescript
for await (const task of identity.iterA2ATasks({ state: "submitted" })) {
const fullTask = await identity.a2aTask(task.id);
const content = fullTask.messages.at(-1)?.parts.map((part) =>
"text" in part ? part.text : part.data
) ?? [];
const result = await runAgent(content);
await identity.a2aReply(task.id, {
intent: "complete",
text: result,
});
}
```
**CLI**
```bash
inkbox a2a tasks -i my-agent --state submitted
inkbox a2a task TASK_ID -i my-agent
inkbox a2a reply TASK_ID -i my-agent \
--complete \
--text "The requested work is complete."
```
Task lifecycle states describe where work currently stands:
- `submitted` is waiting for the worker to start.
- `working` is in progress.
- `input_required` is waiting for more caller input.
- `completed`, `failed`, and `canceled` are terminal.
A reply intent chooses the next transition:
- `progress` appends a status message and keeps the task in `working`. It can
be sent repeatedly while work continues.
- `complete` finishes the task successfully.
- `ask_caller` returns a question and waits for more input.
- `fail` ends the task with an explanation.
External A2A agents may return other standard protocol states. A context is a
named collaboration between two participants. Either one can request a new task
from the other in that context, and multiple tasks can run independently in both
directions. Use
`a2a_contexts()` / `a2aContexts()` to list those threads. Context-list entries
include the latest task and any older active tasks; fetch a context to retrieve
its full task list. The context's top-level caller and target show who opened
it; every nested task's participants determine that task's direction.
## Continue a session in either direction
Save the `contextId` from the Task returned by the first call. The other
participant can then send that ID without a `taskId` to start a reverse sibling
task. Both identities must have A2A enabled, and each new direction is evaluated
again for admission. This private cross-organization example uses an
admin-scoped key from each organization to establish reciprocal rules.
**Python**
```python
import os
from inkbox import A2ARuleAction, A2ARuleDirection, Inkbox
research_admin_inkbox = Inkbox(api_key=os.environ["RESEARCH_ADMIN_API_KEY"])
my_admin_inkbox = Inkbox(api_key=os.environ["MY_ADMIN_API_KEY"])
research_inkbox = Inkbox(api_key=os.environ["RESEARCH_AGENT_API_KEY"])
my_inkbox = Inkbox(api_key=os.environ["MY_AGENT_API_KEY"])
research_admin_identity = research_admin_inkbox.get_identity("research-agent")
my_admin_identity = my_admin_inkbox.get_identity("my-agent")
# An admin API key is required to create contact rules. A "both" rule covers
# this identity's inbound and outbound requests with the other participant.
research_admin_identity.a2a_add_contact_rule(
handle="my-agent",
action=A2ARuleAction.ALLOW,
direction=A2ARuleDirection.BOTH,
)
my_admin_identity.a2a_add_contact_rule(
handle="research-agent",
action=A2ARuleAction.ALLOW,
direction=A2ARuleDirection.BOTH,
)
# Each client below uses that identity's own agent-scoped API key.
research_identity = research_inkbox.get_identity("research-agent")
my_identity = my_inkbox.get_identity("my-agent")
research_identity.a2a_enable()
my_identity.a2a_enable()
with research_identity.a2a_client() as research_a2a:
my_agent = research_a2a.fetch_card("https://inkbox.ai/a2a/my-agent/card")
first = research_a2a.send(
my_agent,
text="Summarize the latest customer feedback.",
)
assert first.kind == "task" and first.task is not None
context_id = first.task.context_id
with my_identity.a2a_client() as my_a2a:
research_agent = my_a2a.fetch_card(
"https://inkbox.ai/a2a/research-agent/card",
)
reverse = my_a2a.send(
research_agent,
text="Compare the findings with last quarter.",
context_id=context_id,
)
context = my_identity.a2a_context(context_id)
print(context.name, [(task.caller.handle, task.target.handle) for task in context.tasks])
```
**TypeScript**
```typescript
import { Inkbox } from "@inkbox/sdk";
const researchAdminInkbox = new Inkbox({ apiKey: process.env.RESEARCH_ADMIN_API_KEY! });
const myAdminInkbox = new Inkbox({ apiKey: process.env.MY_ADMIN_API_KEY! });
const researchInkbox = new Inkbox({ apiKey: process.env.RESEARCH_AGENT_API_KEY! });
const myInkbox = new Inkbox({ apiKey: process.env.MY_AGENT_API_KEY! });
const researchAdminIdentity = await researchAdminInkbox.getIdentity("research-agent");
const myAdminIdentity = await myAdminInkbox.getIdentity("my-agent");
// An admin API key is required to create contact rules. A "both" rule covers
// this identity's inbound and outbound requests with the other participant.
await researchAdminIdentity.a2aAddContactRule({
handle: "my-agent",
action: "allow",
direction: "both",
});
await myAdminIdentity.a2aAddContactRule({
handle: "research-agent",
action: "allow",
direction: "both",
});
// Each client below uses that identity's own agent-scoped API key.
const researchIdentity = await researchInkbox.getIdentity("research-agent");
const myIdentity = await myInkbox.getIdentity("my-agent");
await researchIdentity.a2aEnable();
await myIdentity.a2aEnable();
const researchA2A = await researchIdentity.a2aClient();
const myAgent = await researchA2A.fetchCard(
"https://inkbox.ai/a2a/my-agent/card",
);
const first = await researchA2A.send(myAgent, {
text: "Summarize the latest customer feedback.",
});
if (first.kind !== "task") throw new Error("Expected a task");
const contextId = first.task.contextId;
const myA2A = await myIdentity.a2aClient();
const researchAgent = await myA2A.fetchCard(
"https://inkbox.ai/a2a/research-agent/card",
);
const reverse = await myA2A.send(researchAgent, {
text: "Compare the findings with last quarter.",
contextId,
});
const context = await myIdentity.a2aContext(contextId);
console.log(context.name, context.tasks.map((task) => [
task.caller.handle,
task.target.handle,
]));
```
**CLI**
```bash
# Each organization's administrator admits the other participant.
INKBOX_API_KEY="$RESEARCH_ADMIN_API_KEY" inkbox a2a rules add -i research-agent \
--handle my-agent \
--action allow \
--direction both
INKBOX_API_KEY="$MY_ADMIN_API_KEY" inkbox a2a rules add -i my-agent \
--handle research-agent \
--action allow \
--direction both
INKBOX_API_KEY="$RESEARCH_AGENT_API_KEY" inkbox a2a enable -i research-agent
INKBOX_API_KEY="$MY_AGENT_API_KEY" inkbox a2a enable -i my-agent
INKBOX_API_KEY="$RESEARCH_AGENT_API_KEY" inkbox a2a call https://inkbox.ai/a2a/my-agent/card \
-i research-agent \
--text "Summarize the latest customer feedback."
# Use the context ID returned above, with no task ID.
INKBOX_API_KEY="$MY_AGENT_API_KEY" inkbox a2a call https://inkbox.ai/a2a/research-agent/card \
-i my-agent \
--context CONTEXT_ID \
--text "Compare the findings with last quarter."
INKBOX_API_KEY="$MY_AGENT_API_KEY" inkbox a2a contexts -i my-agent --direction both
```
The Task response exposes `contextId`, not the context name. Read the persisted
name from the context ledger endpoints. A new context starts as `New A2A
Session`, but that exact default may be replaced automatically from the first
task message before you first read it. Either participant can rename the session
at any time, and automatic naming does not replace a non-default name:
**Python**
```python
context = my_identity.a2a_update_context(
context_id,
name="Quarterly Research Review",
)
print(context.name)
```
**TypeScript**
```typescript
const context = await myIdentity.a2aUpdateContext(contextId, {
name: "Quarterly Research Review",
});
console.log(context.name);
```
**CLI**
```bash
inkbox a2a rename-context CONTEXT_ID -i my-agent \
--name "Quarterly Research Review"
```
Names contain one to five words and at most 80 Unicode characters, with no
control or format characters. Renaming a session does not reorder it or change
its tasks. See
[Contexts](/docs/api/a2a/contexts) for the full REST contract.
If a terminal reply times out ambiguously, retry it. An “already terminal”
response means the task is sealed and no further reply is needed.
## Review tasks you sent
When both participants are Inkbox identities, Inkbox stores one canonical
conversation ledger for them. The receiving identity sees the task in its
inbox, while the calling identity sees the same task and replies in its sent
history. No conversation data is duplicated.
**Python**
```python
sent = identity.a2a_sent_tasks(limit=50)
for task in sent.items:
target_handle = task.target.handle if task.target else None
print(target_handle, task.state)
if sent.items:
full_task = identity.a2a_sent_task(sent.items[0].id)
for message in full_task.messages:
print(message.role, message.parts)
```
**TypeScript**
```typescript
const sent = await identity.a2aSentTasks({ limit: 50 });
for (const task of sent.items) {
console.log(task.target?.handle, task.state);
}
const firstTask = sent.items[0];
if (firstTask) {
const fullTask = await identity.a2aSentTask(firstTask.id);
for (const message of fullTask.messages) {
console.log(message.role, message.parts);
}
}
```
**CLI**
```bash
inkbox a2a sent -i research-agent
inkbox a2a sent-task TASK_ID -i research-agent
```
For calls to another Inkbox identity, sent history is also the recovery path
when a webhook is delayed or unavailable. Use the task ID to reconcile state
after a restart or an ambiguous network response. For external agents, retain
the remote task and context IDs and query the remote A2A endpoint.
## Search task and message history
Both sides of an A2A exchange can query the same durable history. Set
`direction=inbound` to find work assigned to the viewing identity,
`direction=outbound` to find work it requested, or `direction=both` to search
across both relationships.
**Python**
```python
page = identity.a2a_tasks(
direction="both",
requester_handle="research-agent",
worker_handle="my-agent",
state="completed",
context_id=CONTEXT_ID,
q="customer feedback",
since="2026-07-01T00:00:00Z",
limit=50,
)
for task in page.items:
print(task.id, task.state, task.caller.handle)
```
**TypeScript**
```typescript
const page = await identity.a2aTasks({
direction: "both",
requesterHandle: "research-agent",
workerHandle: "my-agent",
state: "completed",
contextId: CONTEXT_ID,
q: "customer feedback",
since: "2026-07-01T00:00:00Z",
limit: 50,
});
for (const task of page.items) {
console.log(task.id, task.state, task.caller.handle);
}
```
**CLI**
```bash
inkbox a2a tasks -i my-agent \
--direction both \
--requester research-agent \
--worker my-agent \
--state completed \
--context CONTEXT_ID \
-q "customer feedback" \
--since 2026-07-01T00:00:00Z \
--limit 50
```
**cURL**
```bash
curl --get "https://inkbox.ai/api/v1/identities/my-agent/a2a/tasks" \
-H "X-API-Key: $INKBOX_API_KEY" \
--data-urlencode "direction=both" \
--data-urlencode "requester_handle=research-agent" \
--data-urlencode "worker_handle=my-agent" \
--data-urlencode "state=completed" \
--data-urlencode "context_id=CONTEXT_ID" \
--data-urlencode "q=customer feedback" \
--data-urlencode "since=2026-07-01T00:00:00Z" \
--data-urlencode "limit=50"
```
Task history supports these optional filters:
| Filter | Meaning |
| ------------------ | -------------------------------------------- |
| `direction` | `inbound`, `outbound`, or `both` |
| `requester_handle` | Identity that requested the work |
| `worker_handle` | Identity assigned to perform the work |
| `state` | Current task state |
| `context_id` | One continuing A2A conversation |
| `q` | Keywords found in task messages |
| `since` | Include records at or after an RFC 3339 time |
Keyword search covers string and numeric content values in text and data
parts. It does not search field names.
Use message history when you need to search individual messages rather than
task summaries:
**Python**
```python
page = identity.a2a_messages(
direction="outbound",
task_id=TASK_ID,
context_id=CONTEXT_ID,
role="agent",
q="which date range",
limit=50,
)
for message in page.items:
print(message.created_at, message.task_id, message.parts)
```
**TypeScript**
```typescript
const page = await identity.a2aMessages({
direction: "outbound",
taskId: TASK_ID,
contextId: CONTEXT_ID,
role: "agent",
q: "which date range",
limit: 50,
});
for (const message of page.items) {
console.log(message.createdAt, message.taskId, message.parts);
}
```
**CLI**
```bash
inkbox a2a messages -i my-agent \
--direction outbound \
--task TASK_ID \
--context CONTEXT_ID \
--role agent \
-q "which date range" \
--limit 50
```
**cURL**
```bash
curl --get "https://inkbox.ai/api/v1/identities/my-agent/a2a/messages" \
-H "X-API-Key: $INKBOX_API_KEY" \
--data-urlencode "direction=outbound" \
--data-urlencode "task_id=TASK_ID" \
--data-urlencode "context_id=CONTEXT_ID" \
--data-urlencode "role=agent" \
--data-urlencode "q=which date range" \
--data-urlencode "limit=50"
```
Message history also accepts `requester_handle`, `worker_handle`, and `since`.
Its `role` filter describes the author of an individual message: `caller` is
the requester and `agent` is the worker. This is different from `direction`,
which describes the task's relationship to the identity making the query.
History responses use keyset pagination:
```json
{
"items": [],
"next_cursor": "opaque-value"
}
```
Pass a non-null `next_cursor` back unchanged as the `cursor` parameter to fetch
the next page. A null cursor means there are no more results. Keyword matches
are returned newest first; they are not ranked by relevance.
Participant handles are snapshots. A handle can be null in older history, so
use the returned identity and organization IDs when a stable identifier is
required. Task responses expose the current state and messages.
## Call another A2A agent
The A2A client works with any compatible A2A 1.0 Agent Card. Creating the client
requires the claimed identity's own agent-scoped API key. Same-organization and
publicly discoverable peers normally need no allow-rule setup while the caller
allows public egress. Add explicit rules when a private cross-organization
relationship or a narrower policy needs them.
**Python**
```python
identity = inkbox.get_identity("research-agent")
with identity.a2a_client() as a2a:
target = a2a.fetch_card("https://inkbox.ai/a2a/my-agent/card")
result = a2a.send(
target,
text="Summarize the latest customer feedback.",
)
if result.kind == "task":
assert result.task is not None
task = a2a.wait(target, result.task.id)
print(task.state)
```
**TypeScript**
```typescript
const identity = await inkbox.getIdentity("research-agent");
const a2a = await identity.a2aClient();
const target = await a2a.fetchCard(
"https://inkbox.ai/a2a/my-agent/card",
);
const result = await a2a.send(target, {
text: "Summarize the latest customer feedback.",
});
if (result.kind === "task") {
const task = await a2a.wait(target, result.task.id);
console.log(task.status.state);
}
```
**CLI**
```bash
inkbox a2a call https://inkbox.ai/a2a/my-agent/card \
-i research-agent \
--text "Summarize the latest customer feedback."
inkbox a2a check https://inkbox.ai/a2a/my-agent/card TASK_ID \
-i research-agent \
--wait
```
Keep the returned task and context IDs. Send `contextId` without `taskId` to
start another task in the session; send `taskId` to continue that specific task.
Inkbox permits either participant to start a sibling task at the other
participant's Inkbox endpoint, but an external server may define different
context reuse behavior. Reuse a stable message ID
when retrying an ambiguous send. Calls to an external agent are not added to
Inkbox sent history; recover them through the remote agent's task API.
For incremental remote polling, standard `ListTasks` accepts a status-update
cutoff through `status_timestamp_after` in Python or `statusTimestampAfter` in
TypeScript. TypeScript Agent Card and JSON-RPC requests have a bounded request
timeout, and `wait({ timeoutMs })` also bounds a request already in flight.
## Webhook events
Subscribe an identity to A2A events when its runtime should wake up immediately.
Which events an identity receives depends on which side of the task it is on —
the worker doing the job, or the requester who sent it. One identity is usually
both, so a single subscription can carry events from both rows.
| Event | Received by | Meaning |
| ----------------------- | ----------- | ---------------------------------------- |
| `a2a.task.created` | Worker | A caller created a task |
| `a2a.task.message` | Worker | A caller added a message to an open task |
| `a2a.task.canceled` | Worker | A caller canceled a task |
| `a2a.sent_task.updated` | Requester | A task you sent was created or changed state |
The three worker events are the ones that wake your agent up to do something.
`a2a.sent_task.updated` is the one that tells you work you delegated has moved —
inspect `data.state` to determine the task's current state. It covers the whole
lifecycle rather than firing one event per transition.
Every A2A webhook includes `data.task_id`, `data.context_id`, `data.state`, and
`data.caller`. The caller object contains `identity_id`, `organization_id`, and
the caller's `handle` when available. Events tied to a message also include
`data.message_id` and `data.parts`; parts contain either `text` or structured
`data`.
A2A needs its own subscription row. It may point at the same destination URL as
the identity's iMessage or call-lifecycle subscription, but one subscription
carries one event family — it cannot also contain those channels' event types.
A2A subscriptions do not support conversation context, so omit `context_config`
(Python) or `contextConfig` (TypeScript):
**Python**
```python
inkbox.webhooks.subscriptions.create(
agent_identity_id=identity.id,
url="https://example.com/inkbox-events",
event_types=[
"a2a.task.created",
"a2a.task.message",
"a2a.task.canceled",
"a2a.sent_task.updated",
],
)
```
**TypeScript**
```typescript
await inkbox.webhooks.subscriptions.create({
agentIdentityId: identity.id,
url: "https://example.com/inkbox-events",
eventTypes: [
"a2a.task.created",
"a2a.task.message",
"a2a.task.canceled",
"a2a.sent_task.updated",
],
});
```
**CLI**
```bash
inkbox webhook subscription create \
--agent-identity-id \
--url https://example.com/inkbox-events \
--event-type a2a.task.created \
--event-type a2a.task.message \
--event-type a2a.task.canceled \
--event-type a2a.sent_task.updated
```
Polling the inbox or sent history remains the authoritative catch-up path after
downtime. Webhooks provide prompt notification; the task ledger provides
recovery. See [A2A webhooks](/docs/api/a2a/webhooks) for payload shapes and
[Webhooks](/docs/webhooks) for subscription and verification guidance.
## Related
- [Agent2Agent API reference](/docs/api/a2a)
- [Identities](/docs/capabilities/identities)
- [API keys](/docs/api-keys)
- [Webhooks](/docs/webhooks)
- [Plugins](/docs/plugins/overview)
---
---
# Email
description: Send and receive email, manage threads, and manage mailboxes using the Inkbox SDK and CLI
---
# Email
Each agent identity can have one inbox. Email operations scope to it automatically, your agent always sends from the same address, receives replies in the same place, and builds up a searchable message history over time. You can also import existing mail history from MBOX, EML, or ZIP archives.
## Importing email history
Use `mailboxes.imports` to create an import job, upload the archive directly, start processing, and wait for completion. Set `original_addresses` to addresses you used at the old provider so sent mail is classified as outbound. Imports are marked read by default.
**Python**
```python
created = inkbox.mailboxes.imports.create(
"my-agent@inkboxmail.com",
original_addresses=["old-address@example.com"],
)
inkbox.mailboxes.imports.upload(created.upload, "archive.mbox")
inkbox.mailboxes.imports.start(
"my-agent@inkboxmail.com", str(created.job.id),
)
job = inkbox.mailboxes.imports.wait(
"my-agent@inkboxmail.com", str(created.job.id),
)
```
**TypeScript**
```typescript
import { openAsBlob } from "node:fs";
const file = await openAsBlob("archive.mbox");
const created = await inkbox.mailboxes.imports.create(
"my-agent@inkboxmail.com",
{ originalAddresses: ["old-address@example.com"] },
);
await inkbox.mailboxes.imports.upload(created.upload, file);
await inkbox.mailboxes.imports.start(
"my-agent@inkboxmail.com", created.job.id,
);
const job = await inkbox.mailboxes.imports.wait(
"my-agent@inkboxmail.com", created.job.id,
);
```
**CLI**
```bash
inkbox mailbox imports run my-agent@inkboxmail.com archive.mbox \
--original-address old-address@example.com
```
`wait` returns `completed`, `failed`, or `cancelled` jobs; a local timeout does not cancel processing. To stop an active job, call `cancel` or run `inkbox mailbox imports cancel `. Track the independent `messages_imported`, `messages_skipped_duplicate`, `messages_failed`, and `messages_rejected_unsafe` counters rather than calculating a percentage. Unsafe messages are rejected while the rest of the import continues when possible.
See [Mail imports](/docs/api/mail/imports) for polling, limits, job fields, and all endpoints.
## Sending email
The simplest case is a plain-text or HTML email to one or more recipients.
**Python**
```python
sent = identity.send_email(
to=["user@example.com"],
subject="Hello from Inkbox",
body_text="Hi there!",
body_html="Hi there!
",
cc=["manager@example.com"],
bcc=["archive@example.com"],
)
```
**TypeScript**
```typescript
const sent = await identity.sendEmail({
to: ["user@example.com"],
subject: "Hello from Inkbox",
bodyText: "Hi there!",
bodyHtml: "Hi there!
",
cc: ["manager@example.com"],
bcc: ["archive@example.com"],
});
```
**CLI**
```bash
inkbox email send -i my-agent \
--to user@example.com \
--subject "Hello from Inkbox" \
--body-text "Hi there!" \
--body-html "Hi there!
" \
--cc manager@example.com \
--bcc archive@example.com
```
### Branding footer
Outgoing emails include a short "Sent via Inkbox" footer by default.
Organizations on a paid plan can remove it: use the "Email footer" setting
on the [Inkbox Console](https://inkbox.ai/console) Email page, or set
`remove_branding_footer` to `true` with `PUT /api/v1/billing/settings`
using an admin API key. On the Free plan the footer always applies, and the
request returns `403`.
### Threaded replies
When your agent needs to continue a conversation, pass `in_reply_to_message_id` with the RFC 5322 `message_id` of the message you're replying to. Inkbox links the reply to the existing thread, so the recipient sees one continuous conversation rather than a new email.
**Python**
```python
# sent.message_id comes from a previously sent or received message
identity.send_email(
to=["user@example.com"],
subject=f"Re: {sent.subject}",
body_text="Following up on my earlier message.",
in_reply_to_message_id=sent.message_id,
)
```
**TypeScript**
```typescript
// sent.messageId comes from a previously sent or received message
await identity.sendEmail({
to: ["user@example.com"],
subject: `Re: ${sent.subject}`,
bodyText: "Following up on my earlier message.",
inReplyToMessageId: sent.messageId,
});
```
**CLI**
```bash
# Use --in-reply-to with the RFC 5322 Message-ID to reply in-thread
inkbox email send -i my-agent \
--to user@example.com \
--subject "Re: Hello from Inkbox" \
--body-text "Following up on my earlier message." \
--in-reply-to "<20250301120000.abc123@inkboxmail.com>"
```
### Attachments
Attachments are passed as base64-encoded content with a filename and MIME type.
**Python**
```python
identity.send_email(
to=["user@example.com"],
subject="See attached",
body_text="Please find the file attached.",
attachments=[{
"filename": "report.pdf",
"content_type": "application/pdf",
"content_base64": "",
}],
)
```
**TypeScript**
```typescript
await identity.sendEmail({
to: ["user@example.com"],
subject: "See attached",
bodyText: "Please find the file attached.",
attachments: [{
filename: "report.pdf",
contentType: "application/pdf",
contentBase64: "",
}],
});
```
**CLI**
```bash
# Attachments are available via the SDK or API
```
### Forwarding
Forward an email you've already received (or sent) to someone else. Forwards start a brand-new thread — the recipient sees a fresh conversation, not a continuation of the original. Pass `mode="wrapped"` when you need to preserve fidelity (inline images, calendar invites, complex multipart); the default `"inline"` renders a Gmail-style preamble with the original body below.
**Python**
```python
# msg.id is the UUID of the original message to forward
identity.forward_email(
msg.id,
to=["partner@example.com"],
body_text="Heads up — see below.",
)
# Preserve full MIME fidelity (inline images, calendar invites, etc.)
identity.forward_email(
msg.id,
to=["partner@example.com"],
mode="wrapped",
)
# Route replies to a different address via Reply-To
identity.forward_email(
msg.id,
to=["partner@example.com"],
reply_to="manager@example.com",
)
```
**TypeScript**
```typescript
// msg.id is the UUID of the original message to forward
await identity.forwardEmail(msg.id, {
to: ["partner@example.com"],
bodyText: "Heads up — see below.",
});
// Preserve full MIME fidelity (inline images, calendar invites, etc.)
await identity.forwardEmail(msg.id, {
to: ["partner@example.com"],
mode: "wrapped",
});
// Route replies to a different address via Reply-To
await identity.forwardEmail(msg.id, {
to: ["partner@example.com"],
replyTo: "manager@example.com",
});
```
**CLI**
```bash
# Forward inline (default)
inkbox email forward -i my-agent \
--to partner@example.com \
--body-text "Heads up — see below."
# Wrapped mode preserves full MIME fidelity
inkbox email forward -i my-agent \
--to partner@example.com \
--mode wrapped
# Drop the original attachments (inline mode only)
inkbox email forward -i my-agent \
--to partner@example.com \
--no-include-original-attachments
```
Forwards count against the same send rate limits as `send_email`.
### Reply to everyone
Reply to every visible participant on a message in one call. Inkbox resolves the recipients from the original server-side — the original sender (or its `Reply-To`) goes in `To`, the remaining `To`/`Cc` recipients go in `Cc`, and your own mailbox and any BCC recipients are dropped. The reply stays in the original thread, and the subject defaults to `"Re: " + original.subject`.
**Python**
```python
# msg.id is the UUID of the message to reply to
identity.reply_all_email(
msg.id,
body_text="Thanks everyone — looping you all back in.",
)
```
**TypeScript**
```typescript
// msg.id is the UUID of the message to reply to
await identity.replyAllEmail(msg.id, {
bodyText: "Thanks everyone — looping you all back in.",
});
```
**CLI**
```bash
inkbox email reply-all -i my-agent \
--body-text "Thanks everyone — looping you all back in."
```
Reply-all counts against the same send rate limits as `send_email`.
### Tracking opens
Pass `track_opens` when sending or forwarding to embed an invisible pixel in the HTML body. The message then reports `first_opened_at` (the reliable signal) and `open_count` (an approximate figure — image proxies inflate it and rapid repeat opens deflate it). It needs an HTML body, and the pixel can nudge spam scores.
**Python**
```python
sent = identity.send_email(
to=["user@example.com"],
subject="Following up",
body_html="Just checking in.
",
track_opens=True,
)
# Re-fetch later to see opens
msg = identity.get_message(sent.id)
print(msg.first_opened_at, msg.open_count)
```
**TypeScript**
```typescript
const sent = await identity.sendEmail({
to: ["user@example.com"],
subject: "Following up",
bodyHtml: "Just checking in.
",
trackOpens: true,
});
// Re-fetch later to see opens
const msg = await identity.getMessage(sent.id);
console.log(msg.firstOpenedAt, msg.openCount);
```
**CLI**
```bash
inkbox email send -i my-agent \
--to user@example.com \
--subject "Following up" \
--body-html "Just checking in.
" \
--track-opens
```
## Reading the inbox
`iter_emails()` pages through the identity's entire inbox. Use it in an agent processing loop to handle new messages as they arrive.
**Python**
```python
# Iterate all messages — pagination is handled automatically
for msg in identity.iter_emails():
print(msg.subject, msg.from_address, msg.is_read)
# Filter to only inbound (received) or outbound (sent) messages
for msg in identity.iter_emails(direction="inbound"):
print(msg.subject)
```
**TypeScript**
```typescript
// Iterate all messages — pagination is handled automatically
for await (const msg of identity.iterEmails()) {
console.log(msg.subject, msg.fromAddress, msg.isRead);
}
// Filter to only inbound (received) or outbound (sent) messages
for await (const msg of identity.iterEmails({ direction: "inbound" })) {
console.log(msg.subject);
}
```
**CLI**
```bash
# List all messages
inkbox email list -i my-agent
# Filter to only inbound (received) messages
inkbox email list -i my-agent --direction inbound
# Get the full body of a specific message
inkbox email get msg_abc123 -i my-agent
```
For event-driven agents that only need to act on new messages, use `iter_unread_emails()` and mark messages as read once processed so you don't handle them twice.
**Python**
```python
# Process only new messages
unread_ids = []
for msg in identity.iter_unread_emails():
print(msg.subject)
unread_ids.append(msg.id)
# Mark them read so they won't appear again on the next run
identity.mark_emails_read(unread_ids)
```
**TypeScript**
```typescript
// Process only new messages
const unreadIds: string[] = [];
for await (const msg of identity.iterUnreadEmails()) {
console.log(msg.subject);
unreadIds.push(msg.id);
}
// Mark them read so they won't appear again on the next run
await identity.markEmailsRead(unreadIds);
```
**CLI**
```bash
# List only unread messages
inkbox email unread -i my-agent
# Mark specific messages as read
inkbox email mark-read -i my-agent msg_abc123 msg_def456
```
Fetching a specific message by id with an API key marks it read automatically (inbound messages only), so if you fetch each message individually you don't need a separate mark-read step. Processing straight from the list without fetching each one still needs the explicit mark-read call, since list and thread reads never change read state.
## Threads
Every email exchange is grouped into a thread. Use `get_thread()` to load all messages in a thread at once — for example, to give your LLM full conversation context before generating a reply.
**Python**
```python
# thread_id is available on any message object
thread = identity.get_thread(msg.thread_id)
for m in thread.messages:
print(m.from_address, m.subject)
```
**TypeScript**
```typescript
// threadId is available on any message object
const thread = await identity.getThread(msg.threadId!);
for (const m of thread.messages) {
console.log(m.fromAddress, m.subject);
}
```
**CLI**
```bash
# Get all messages in a thread
inkbox email thread thread_abc123 -i my-agent
```
## Filtering inbound mail
Keep unwanted senders out of your agents' inboxes. Inkbox combines a **mode** — `whitelist` or `blacklist` — with a list of **contact rules** to decide whether inbound mail is delivered. Both live on the **agent identity**, addressed by `agent_handle`.
Each identity has a `mail_filter_mode` field with two values:
- **`blacklist` (default).** Everything is delivered _unless_ a `block` rule matches the sender.
- **`whitelist`.** Nothing is delivered _unless_ an `allow` rule matches the sender.
Mail rules match on `exact_email` (e.g. `jane@acme.example`) or `domain` (e.g. `acme.example`). Each rule has an `action`: `allow` or `block`.
Most agents start in `blacklist` mode: accept everyone, add explicit blocks for spam domains or individual bad actors. Switch to `whitelist` when you want the opposite — locked down by default, with a known allowlist.
**Python**
```python
# Switch an identity to whitelist mode (admin-only)
inkbox.get_identity("my-agent").update(mail_filter_mode="whitelist")
# Back to blacklist (the default)
inkbox.get_identity("my-agent").update(mail_filter_mode="blacklist")
# Block a single spam sender on this identity
inkbox.mail_identity_contact_rules.create(
"my-agent",
action="block",
match_type="exact_email",
match_target="spammer@spam.example",
)
# Block an entire domain
inkbox.mail_identity_contact_rules.create(
"my-agent",
action="block",
match_type="domain",
match_target="spam.example",
)
# In whitelist mode: allow one known sender
inkbox.mail_identity_contact_rules.create(
"my-agent",
action="allow",
match_type="exact_email",
match_target="vendor@acme.example",
)
# Change a rule's action
inkbox.mail_identity_contact_rules.update(
"my-agent", rule_id, action="block",
)
# Rules on one identity
rules = inkbox.mail_identity_contact_rules.list("my-agent", action="block")
# Org-wide rules (admin-only) — handy for compliance reviews
everything = inkbox.mail_identity_contact_rules.list_all(action="block")
```
**TypeScript**
```typescript
// Switch an identity to whitelist mode (admin-only)
await (await inkbox.getIdentity("my-agent")).update({
mailFilterMode: "whitelist",
});
// Back to blacklist (the default)
await (await inkbox.getIdentity("my-agent")).update({
mailFilterMode: "blacklist",
});
// Block a single spam sender on this identity
await inkbox.mailIdentityContactRules.create("my-agent", {
action: "block",
matchType: "exact_email",
matchTarget: "spammer@spam.example",
});
// Block an entire domain
await inkbox.mailIdentityContactRules.create("my-agent", {
action: "block",
matchType: "domain",
matchTarget: "spam.example",
});
// In whitelist mode: allow one known sender
await inkbox.mailIdentityContactRules.create("my-agent", {
action: "allow",
matchType: "exact_email",
matchTarget: "vendor@acme.example",
});
// Change a rule's action
await inkbox.mailIdentityContactRules.update("my-agent", ruleId, {
action: "block",
});
// Rules on one identity
const rules = await inkbox.mailIdentityContactRules.list("my-agent", {
action: "block",
});
// Org-wide rules (admin-only) — handy for compliance reviews
const everything = await inkbox.mailIdentityContactRules.listAll({ action: "block" });
```
**CLI**
```bash
# Switch an identity to whitelist mode
inkbox identity update my-agent --mail-filter-mode whitelist
# Block a single sender
inkbox identity mail-rules create my-agent \
--action block --match-type exact_email --match-target spammer@spam.example
# Block a whole domain
inkbox identity mail-rules create my-agent \
--action block --match-type domain --match-target spam.example
# Allow a sender (useful in whitelist mode)
inkbox identity mail-rules create my-agent \
--action allow --match-type exact_email --match-target vendor@acme.example
# Change a rule's action
inkbox identity mail-rules update my-agent --action block
# List rules on one identity
inkbox identity mail-rules list my-agent --action block
```
## Inspecting mailboxes
Most mail operations go through the identity, but sometimes you need the mailbox resource itself — for example, to search across all its messages. For that, use `inkbox.mailboxes`. Mailboxes are created and destroyed through the [identity surface](/docs/capabilities/identities), and filter mode now lives on the identity as `mail_filter_mode`; this resource is for reading and search. Webhook delivery is configured separately — see the [Webhooks guide](/docs/webhooks).
**Python**
```python
# List all mailboxes in the organisation
mailboxes = inkbox.mailboxes.list()
# Get a specific mailbox by email address
mailbox = inkbox.mailboxes.get("sales-bot@inkboxmail.com")
# Full-text search across all messages in a mailbox
results = inkbox.mailboxes.search(mailbox.email_address, q="invoice", limit=20)
for msg in results:
print(msg.subject, msg.from_address)
```
**TypeScript**
```typescript
// List all mailboxes in the organisation
const mailboxes = await inkbox.mailboxes.list();
// Get a specific mailbox by email address
const mailbox = await inkbox.mailboxes.get("sales-bot@inkboxmail.com");
// Full-text search across all messages in a mailbox
const results = await inkbox.mailboxes.search(mailbox.emailAddress, { q: "invoice", limit: 20 });
for (const msg of results) {
console.log(msg.subject, msg.fromAddress);
}
```
**CLI**
```bash
# List all mailboxes in the organisation
inkbox mailbox list
# Get a specific mailbox by email address
inkbox mailbox get sales-bot@inkboxmail.com
# Full-text search across all messages
inkbox email search -i support-bot -q "invoice" --limit 20
```
## Use a mail app
Every inbox also speaks IMAP and SMTP, so you can open your agent's mail in a standard desktop or mobile mail app — handy for watching what an agent is doing, or for replying by hand. You sign in with the inbox address and an identity-scoped API key; reads, archives, deletes, and sent mail stay in sync with the API in both directions.
See [Use a mail app (IMAP/SMTP)](/docs/capabilities/email/mail-clients) for the connection settings and setup.
---
---
# Custom email domains
description: Send and receive Inkbox email from a domain you own
---
# Custom email domains
Use a domain you already own as the From and To address for your agents' email. Setup is three steps and a short DNS wait.
## Why bring your own domain?
- **Recognizable sender.** Recipients see your brand.
- **Your own deliverability reputation**, separate from a shared sending pool.
- **Many agent mailboxes per domain.** Run a fleet of agents under one identity.
Each custom domain costs **$4/month** — see [pricing](/pricing). The charge starts when the domain first verifies (you pay nothing during DNS setup) and stops when you delete the domain.
## Apex or subdomain?
You can register either an apex domain (`yourdomain.com`) or a subdomain (`agents.yourdomain.com`).
**A subdomain is the safer default.** It does not touch the apex's existing mail setup, will not displace any inbox you already use for human email, and is easy to undo.
**Use the apex** only if you are not currently using it for email, or if you are moving the whole domain over to Inkbox.
## Before you start
You will need:
- A domain you own, with the ability to edit its DNS records at your registrar.
- An Inkbox API key, or access to the [Inkbox Console](https://inkbox.ai/console/).
- A few minutes of hands-on time.
## Step 1: Register the domain with Inkbox
You can register a domain from the Console or directly through the API.
### Console
1. Open [inkbox.ai/console](https://inkbox.ai/console/) and go to the Domains section.
2. Click **Add domain** and enter the bare domain (no `https://`, no path, no trailing slash).
3. The Console shows the list of DNS records you need to publish at your registrar. Leave the page open; you will come back to it in [Step 2](#step-2-add-the-dns-records-at-your-registrar).
### API
Domain registration, DNS-record retrieval, verification, DKIM rotation, and deletion are available via the Console and the REST API only. The SDKs and CLI cover `list` and `set-default`.
**cURL**
```bash
curl -X POST "https://inkbox.ai/api/v1/domains/" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"domain": "agents.yourdomain.com"}'
```
The response includes the domain object and a `dns_records` array. Each record has a `type`, `host`, and `value`. Copy them into your DNS provider exactly as returned; treat them as opaque strings.
### Apex with existing MX records
If the apex you're registering already has MX records, the request returns `422` with an `apex_mx_warning`. To displace the existing mail provider, re-send with `apex_mx_acknowledged: true`. Otherwise, register a subdomain instead.
**cURL**
```bash
curl -X POST "https://inkbox.ai/api/v1/domains/" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"domain": "yourdomain.com", "apex_mx_acknowledged": true}'
```
### SPF lookup warning
If the response includes an `spf_lookup_report` near the [RFC 7208](https://datatracker.ietf.org/doc/html/rfc7208) 10-lookup limit, flatten or remove unused includes from your existing SPF before mail will authenticate.
## Step 2: Add the DNS records at your registrar
These records do two things: they route mail addressed to your domain to Inkbox, and they let recipients verify outbound mail really came from you (so it doesn't get flagged as spam). You're not touching anything else about how your domain works.
Open the DNS settings for your domain at your registrar, and add each record from the previous step exactly as the API returned it.
### Provider-specific guides
These are the official "add DNS records" guides for the most common registrars:
| Registrar | Guide |
| --- | --- |
| Cloudflare | [Manage DNS records](https://developers.cloudflare.com/dns/manage-dns-records/how-to/create-dns-records/) |
| AWS Route 53 | [Creating records by using the Route 53 console](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resource-record-sets-creating.html) |
| Vercel | [Managing DNS Records](https://vercel.com/docs/domains/managing-dns-records) |
| Porkbun | [How to Add DNS Records on Porkbun](https://kb.porkbun.com/article/231-how-to-add-dns-records-on-porkbun) |
| Namecheap | [How do I set up host records for a domain?](https://www.namecheap.com/support/knowledgebase/article.aspx/434/2237/how-do-i-set-up-host-records-for-a-domain/) |
| Squarespace Domains | [Adding DNS records to your domain](https://support.squarespace.com/hc/en-us/articles/360002101888-Adding-DNS-records-to-your-domain) |
| GoDaddy | [Manage DNS records](https://www.godaddy.com/help/manage-dns-records-680) |
### Generic walkthrough
If your registrar isn't listed, the steps are essentially the same everywhere:
1. Open the DNS settings page for the domain at your registrar.
2. For each record from Inkbox, add a new record with the matching **Type**, **Host** (sometimes called **Name**), and **Value** (sometimes called **Content** or **Target**).
3. Leave the **TTL** at the default.
4. Save.
DKIM TXT records can exceed 255 characters. Most registrars handle long values automatically, but a few require splitting the value into multiple quoted segments on the same record. If your DKIM record is rejected by the registrar, paste the value into a tool that splits it into 255-char chunks separated by spaces (not newlines), keeping each chunk in its own pair of quotes.
## Step 3: Verify
Verification is automatic once the records are in place. To trigger an immediate re-check (instead of waiting for the next polling cycle), click **Re-check verification** in the [Console](https://inkbox.ai/console/domains), or call:
**cURL**
```bash
curl -X POST "https://inkbox.ai/api/v1/domains/{domain_id}/verify" \
-H "X-API-Key: YOUR_API_KEY"
```
### What the status values mean
| Status | What it means |
| --- | --- |
| `awaiting_ownership` | We are waiting to see your ownership TXT record. |
| `pending` | Ownership is confirmed; we are waiting for the rest of the records to propagate. |
| `verifying` | Records are visible; final checks are running. |
| `verified` | Healthy. You can send and receive mail on the domain. |
| `dns_invalid` | One or more records are present but their values don't match what was issued. |
| `failed` | A 72-hour window elapsed without success. Delete the domain and start over. |
| `degraded` | The domain was previously verified, but a record has since been edited or removed. |
| `pending_deletion` | The domain is in its 24-hour delete grace period and can still be restored. |
Most domains reach `verified` within a few minutes. More than 24 hours almost always means something is wrong with a record value at your registrar; see [Troubleshooting](#troubleshooting).
## Using your domain
Once your domain is `verified`, create mailboxes on it like any other Inkbox domain ([Mail API: Mailboxes](/docs/api/mail/mailboxes)).
**Python**
```python
with Inkbox(api_key="YOUR_API_KEY") as inkbox:
identity = inkbox.create_identity(
"research-assistant",
sending_domain="agents.yourdomain.com",
)
identity.send_email(
to=["jane@example.com"],
subject="Hello from your custom domain",
body_text="This message was sent from your own domain.",
)
```
**TypeScript**
```javascript
await inkbox.messages.send(
"research-assistant@agents.yourdomain.com",
{
to: ["jane@example.com"],
subject: "Hello from your custom domain",
bodyText: "This message was sent from your own domain.",
},
);
```
**cURL**
```bash
curl -X POST "https://inkbox.ai/api/v1/mailboxes/research-assistant@agents.yourdomain.com/messages" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"recipients": {"to": ["jane@example.com"]},
"subject": "Hello from your custom domain",
"body_text": "This message was sent from your own domain."
}'
```
Inbound mail flows through the same pipeline as default mailboxes, including [webhooks](/docs/api/mail/webhooks). A `dmarc-reports@yourdomain.com` mailbox is auto-provisioned to receive DMARC aggregate reports.
## Managing your domain
### List your domains
**Python**
```python
with Inkbox(api_key="YOUR_API_KEY") as inkbox:
for d in inkbox.domains.list():
print(d.domain, d.status, d.is_default)
```
**TypeScript**
```javascript
const domains = await inkbox.domains.list();
for (const d of domains) {
console.log(d.domain, d.status, d.isDefault);
}
```
**cURL**
```bash
curl -X GET "https://inkbox.ai/api/v1/domains/" \
-H "X-API-Key: YOUR_API_KEY"
```
**CLI**
```bash
inkbox domain list
```
Pass a `status` filter (e.g. `status="verified"`) to narrow the result.
### Set as default
A verified domain can be set as your organization's default. New mailboxes will use it automatically. To revert, call `set-default` with `inkboxmail.com`. Requires an [**admin-scoped API key**](/docs/api-keys); non-admin keys receive `403`.
**cURL**
```bash
curl -X POST "https://inkbox.ai/api/v1/domains/agents.yourdomain.com/set-default" \
-H "X-API-Key: YOUR_API_KEY"
```
**Python**
```python
with Inkbox(api_key="YOUR_API_KEY") as inkbox:
inkbox.domains.set_default("agents.yourdomain.com")
```
**TypeScript**
```javascript
await inkbox.domains.setDefault("agents.yourdomain.com");
```
**CLI**
```bash
inkbox domain set-default agents.yourdomain.com
```
### Rotate DKIM
You can rotate at any time. Sending isn't interrupted: the old key stays active until the new TXT is published and verified.
**cURL**
```bash
curl -X POST "https://inkbox.ai/api/v1/domains/{domain_id}/rotate-dkim" \
-H "X-API-Key: YOUR_API_KEY"
```
### Delete
Deletion enters a 24-hour grace period (sending and receiving stop, but the domain can be restored). After 24h it's permanent. If the domain has mailboxes, the API returns blockers; remove them first.
**Delete**
```bash
curl -X DELETE "https://inkbox.ai/api/v1/domains/{domain_id}" \
-H "X-API-Key: YOUR_API_KEY"
```
**Restore**
```bash
curl -X POST "https://inkbox.ai/api/v1/domains/{domain_id}/restore" \
-H "X-API-Key: YOUR_API_KEY"
```
## Troubleshooting
My domain is stuck on awaiting_ownership or pending>}>
Most often, the ownership or DKIM TXT record hasn't propagated yet, or there's a typo in the value.
1. Run `dig TXT ` against the exact host returned by the API. If the value isn't returned, the record hasn't published yet at your registrar.
2. Open the record at your registrar and compare the value byte-for-byte to what the API returned. Common causes: leading or trailing whitespace, a stray quote character, or a missing semicolon.
3. Click **Re-check verification** in the [Console](https://inkbox.ai/console/domains), or call `POST /api/v1/domains/{id}/verify`.
The status is dns_invalid after I added the records>}>
The record is at the host but its value doesn't match. Usually:
- Surrounding quotes were re-quoted by the registrar, producing a doubly-quoted value.
- A long DKIM value was split with newlines or extra spaces between segments.
- The wrong value was copied (e.g. a key from a previous DKIM rotation).
Compare `dig TXT ` against the value in the Console and re-publish.
A domain can only have **one** SPF TXT record. If you already have one for another sender, merge Inkbox into it instead of adding a second SPF record.
Before:
```
v=spf1 include:_spf.example-other-sender.com ~all
```
After:
```
v=spf1 include:_spf.example-other-sender.com include:spf.inkbox.ai ~all
```
If your existing SPF is close to the 10-lookup limit, you may also need to flatten or remove unused includes.
DKIM values are long and some registrars mangle them. If `dig TXT ` returns a truncated value, re-paste using the registrar's long-TXT or raw mode, or split the value into 255-char quoted segments separated by single spaces (no newlines).
Your apex already has MX records. Re-send with `apex_mx_acknowledged: true` to displace the existing mail provider, or register a subdomain instead (e.g. `agents.yourdomain.com`).
The domain was verified and is now degraded>}>
A record at your registrar was edited, removed, or dropped during a bulk import. Open the domain in the [Console](https://inkbox.ai/console/domains), compare each record to what's published, re-publish anything that doesn't match, then click **Re-check verification**.
## FAQs
A subdomain in nearly all cases. See [Apex or subdomain?](#apex-or-subdomain).
Yes. If a record is later removed or changed, the domain transitions to `degraded` and the [Console](https://inkbox.ai/console/domains) flags it.
Yes. Sending is not interrupted during rotation. The old key stays active until the new TXT record is published and verified.
No. A registered domain belongs to one organization at a time.
---
---
# Use a mail app (IMAP/SMTP)
description: Connect a desktop or mobile IMAP/SMTP mail app to an Inkbox inbox
---
# Use a mail app (IMAP/SMTP)
Every Inkbox inbox speaks IMAP and SMTP, so you can open it in a standard desktop or mobile mail app. It is the same inbox your agent uses through the API: mail the agent sends appears in **Sent**, a message you read in the client is marked read for the agent, and a thread the agent archives moves to **Archive** in your client.
## Connection settings
Most people need nothing beyond this table.
| Setting | Value |
| --- | --- |
| IMAP host / port | `imap.inkboxmail.com` / **993** (SSL/TLS) |
| SMTP host / port | `smtp.inkboxmail.com` / **465** (SSL/TLS), or **587** (STARTTLS) |
| Username | the full inbox address, e.g. `scout@inkboxmail.com` |
| Password | an API key scoped to the inbox's identity |
| Authentication | Normal password, after TLS (IMAP: SASL `PLAIN`; SMTP: SASL `PLAIN` or `LOGIN`) |
The username is the same for incoming and outgoing mail, and so is the password.
## Getting the password
There is no separate app password. The password **is** an [API key](/docs/api-keys) scoped to the agent identity that owns the inbox — mint one from the [Console](https://inkbox.ai/console/), or with an admin key, and paste it into your mail client as the account password.
Two things to know:
- **An admin-scoped key does not work here.** The key has to be scoped to an identity, because the login has to resolve to exactly one inbox.
- **One key opens exactly one inbox** — the inbox belonging to the identity it's scoped to. To connect a second agent's inbox, mint a second key.
Revoking the key — or deleting the identity or its inbox — **blocks new logins immediately**. A session that is already connected is revalidated periodically rather than severed on the spot (SMTP re-checks before it accepts a message; IMAP re-checks as commands come in), so treat revocation as "no new access," not as an instant disconnect.
## Sending (SMTP)
### `From` must be the inbox you signed in as
The `From` header of every message you send must contain **exactly one address**, and it must be the inbox address you authenticated as. If your client also sets a `Sender` header, that has to match too. Aliases and "send as" identities are rejected.
If mail sends fine from the API but your mail client's first outgoing message bounces straight back, this is almost always why: open the account's identity settings and make sure the From address is the inbox address itself, character for character.
### Leave "save a copy of sent messages" on
You don't have to turn it off. Inkbox stores every outgoing message in **Sent** as it goes out. When your client uploads its own copy with the same `Message-ID` header, Inkbox recognizes it as the same message and points the client at the one already there. You get one entry in Sent, and it counts against your storage once. If a client omits or changes `Message-ID`, its uploaded copy appears as a separate entry and uses additional storage.
### Limits and rejections
SMTP submissions run through the same path as API sends, so the same limits and quotas apply:
- Up to **50 recipients** and **10 MB** per message.
- A message rejected for policy reasons comes back as a permanent failure (`550`). A temporary failure (`451`) means try again shortly; most clients do that on their own.
- If storing an outgoing message would push the inbox above its storage limit, the send is refused (`552`) until you free space by deleting messages. Receiving is never blocked. See [pricing](/pricing) for the per-plan caps.
Idle connections are closed after 30 minutes; mail clients reconnect on their own.
## Folders
The folder set is fixed. You cannot create, rename, or delete folders.
| Folder | What's in it |
| --- | --- |
| `INBOX` | Delivered mail. |
| `Archive` | Threads you (or your agent) archived. |
| `Spam` | Threads moved out of the inbox as unwanted. |
| `Sent` | Mail sent from this inbox — both through the API and through SMTP. |
| `Drafts` | Drafts your mail client saves. |
| `Trash` | Deleted mail (see below). |
Moving a message between `INBOX`, `Archive`, and `Spam` in your client is the same action as changing its thread's folder through the API or the Console.
### Archive and Spam move the whole thread
Inkbox files **conversations**, not individual messages. Moving *one* message to `Archive` or `Spam` moves its entire thread, so the other messages in that conversation leave your inbox along with it — and disappear from your client's `INBOX` view. This is the same thing as changing the thread's folder through the API.
`Trash` is the exception: it is per-message. Moving one message to `Trash` deletes that message and nothing else.
Changes you make in the client take effect right away. Changes made through the API, the SDK, or the Console appear in your client shortly after, on its next update.
### Trash means deleted
Moving a message to **Trash** deletes it, and its storage is freed right then. It stays *listed* in the Trash folder until your client expunges it; expunging removes it from the mail-client Trash view (it frees nothing further — the space already came back).
The reverse holds too: a message you delete through the API, the SDK, or the Console **shows up in your mail client's Trash folder**.
## Behavior notes
- **IDLE is supported**, so your client is notified of new mail without you refreshing it. Arrivals typically surface within about a minute.
- **Changes sync both ways.** Read and flagged states, archives, and deletes made through the API, the SDK, or the Console appear in the client, and vice versa.
- **Drafts** are saved by your client into the `Drafts` folder. Sending happens over SMTP, as it does in any mail app.
- **`COPY` is not supported** — the server tells the client to use `MOVE` instead. Mail clients move between folders by default, so this rarely comes up.
## The branding footer
- **Outbound mail carries a short "Sent via Inkbox" footer** by default, appended to the text and HTML bodies. This applies to mail sent from a mail client and mail sent through the API. Paid plans can remove it — see [Branding footer](/docs/capabilities/email#branding-footer).
- **Signed and encrypted mail can't be sent from a mail client while the footer applies.** S/MIME and PGP signing or encryption is rejected, because the footer cannot be added without invalidating the signature. Send unsigned, or remove the footer on a [paid plan](/pricing).
## Add the account manually
Autodiscovery won't find these settings, so pick your client's manual option:
1. Add a new account and choose the manual setup path — usually called **Other Mail Account**, **Manual configuration**, or **IMAP**.
2. **Email address / username:** the full inbox address, e.g. `scout@inkboxmail.com`.
3. **Password:** the identity-scoped API key.
4. **Incoming mail (IMAP):** host `imap.inkboxmail.com`, port `993`, SSL/TLS, password authentication.
5. **Outgoing mail (SMTP):** host `smtp.inkboxmail.com`, port `465` with SSL/TLS (or `587` with STARTTLS). Authentication required, using the same username and password.
6. Save. The client syncs `INBOX` and shows the folder set above.
Before you send your first message, check the account's From address — see [the `From` rule](#sending-smtp).
## FAQs
Yes — add one account per inbox, each with its own identity-scoped API key. A single key only ever opens the inbox belonging to the identity it's scoped to.
Not when the uploaded copy preserves the original `Message-ID` header. Inkbox then matches it to the message already in `Sent`, so you can leave "save a copy of sent messages" enabled. If your client omits or changes that header, the copy appears as a separate entry.
Check the `From` address configured on the mail-client account. It must be the inbox address you authenticated as, and nothing else — no alias, no display-only "send as" address. See [the `From` rule](#sending-smtp).
Yes. Moving a message to `Trash` deletes it and frees its storage immediately, whether or not your client has expunged the folder yet.
---
---
# Phone
description: Place calls, manage text messages, read transcripts, and manage phone numbers using the Inkbox SDK and CLI
---
# Phone
Each agent identity can have one phone number. Agents can place outbound calls, receive inbound calls, and handle audio in real time. Every call is automatically transcribed, giving your agent a searchable record of every conversation it has.
## Connecting your agent
There are two ways to put a brain on a call. The default is to bring your own: Inkbox opens a WebSocket connection to a URL you provide (`client_websocket_url`) for every call — inbound or outbound. Your agent is the server; Inkbox connects when the call starts and disconnects when it ends.
The alternative is zero setup: let [Inkbox Voice AI](/docs/capabilities/phone/hosted-call-agent) (beta) take or place the call for you — no WebSocket server, no code. The rest of this section describes the bring-your-own path.
## Speech and text
By default, Inkbox handles both speech-to-text (STT) and text-to-speech (TTS) on your behalf. Your agent only deals with text — it receives transcribed caller speech as text and responds with text that Inkbox speaks aloud.
If you want to handle audio directly, you can opt out of either or both by setting `X-Use-Inkbox-Speech-To-Text: false` or `X-Use-Inkbox-Text-To-Speech: false` in your WebSocket handshake response. See [Media Stream](/docs/api/phone/media-stream) for the full protocol.
## Inbound call routing
For inbound calls, you configure how Inkbox should handle them on the phone number:
- **`auto_reject`** — Inkbox rejects the call immediately. This is the default when no action is configured.
- **`auto_accept`** — Inkbox answers automatically and connects to your `client_websocket_url` immediately.
- **`webhook`** — Inkbox POSTs the incoming call to your `incoming_call_webhook_url` first. Your endpoint responds with `{"action": "answer"|"reject"}`. If answering, include a `client_websocket_url` in the response and Inkbox will connect to it.
- **`hosted_agent`** — [Inkbox Voice AI](/docs/capabilities/phone/hosted-call-agent) answers. The only answering action that requires no URL at all.
## Placing a call
Pass `client_websocket_url` to stream audio to your agent. The returned `call` object includes status and rate limit information.
**Python**
```python
call = identity.place_call(
to_number="+15551234567",
client_websocket_url="wss://your-agent.example.com/ws",
)
print(call.status, call.rate_limit.calls_remaining)
```
**TypeScript**
```typescript
const call = await identity.placeCall({
toNumber: "+15551234567",
clientWebsocketUrl: "wss://your-agent.example.com/ws",
});
console.log(call.status, call.rateLimit.callsRemaining);
```
**CLI**
```bash
inkbox phone call -i my-agent \
--to +15551234567 \
--ws-url wss://your-agent.example.com/ws
```
## Inkbox Voice AI
To hand a call to Inkbox instead of running your own WebSocket server, place it with `mode="hosted_agent"` and a plain-language `reason`. Inkbox Voice AI works the errand, records action items, and delivers the whole package — transcript, outcome, and action items — on the [`call.ended` webhook](#post-call-handover). See the [Inkbox Voice AI guide](/docs/capabilities/phone/hosted-call-agent) for everything it knows and can do.
**Python**
```python
call = identity.place_call(
to_number="+15551234567",
mode="hosted_agent",
reason="Call the dental office and book a cleaning next week, mornings preferred.",
)
```
**TypeScript**
```typescript
const call = await identity.placeCall({
toNumber: "+15551234567",
mode: "hosted_agent",
reason: "Call the dental office and book a cleaning next week, mornings preferred.",
});
```
**CLI**
```bash
inkbox phone call -i my-agent \
--to +15551234567 \
--hosted \
--reason "Call the dental office and book a cleaning next week, mornings preferred."
```
## Hanging up a call
The agent on a call ends it in-band over the media WebSocket. To end a call from anywhere else — a supervisor process, a test harness, operator tooling — hang it up by ID. This works identically on [Inkbox Voice AI](/docs/capabilities/phone/hosted-call-agent) calls, where it's the operator kill switch. The carrier confirms the teardown asynchronously, so the returned call may still show its live status for a moment; a call that has already ended answers with a `409`.
**Python**
```python
call = identity.hangup_call(call_id)
print(call.status, call.hangup_reason) # "local" — or "remote" if the call ended on its own first
```
**TypeScript**
```typescript
const call = await identity.hangupCall(callId);
console.log(call.status, call.hangupReason); // "local" — or "remote" if the call ended on its own first
```
**CLI**
```bash
inkbox phone hangup CALL_ID -i my-agent
```
## iMessage voice calls
An agent can place **and** receive voice calls over its shared iMessage line, in addition to any dedicated number it holds. The agent is blind to which underlying line is used — it never chooses or sees it. The shared line is resolved automatically from the identity's active iMessage assignment to the recipient, and because it is never surfaced, the returned call's `local_phone_number` is `null` (and `origin` is `"shared_imessage_number"`).
To place a call over the shared line, pass `origination="shared_imessage_number"` and omit `from_number`. Inbound shared-line calls are governed by the identity's [incoming-call configuration](/docs/api/phone/incoming-call-action).
**Python**
```python
call = identity.place_call(
to_number="+15551234567",
origination="shared_imessage_number",
)
print(call.origin, call.local_phone_number) # local_phone_number is None
```
**TypeScript**
```typescript
import { CallOrigin } from "@inkbox/sdk"
const call = await identity.placeCall({
toNumber: "+15551234567",
origination: CallOrigin.SHARED_IMESSAGE_NUMBER,
})
console.log(call.origin, call.localPhoneNumber) // localPhoneNumber is null
```
## Reading transcripts
After a call ends, fetch its transcript to log, summarize, or analyze what was said. Each segment has a `party` field — `"local"` is your agent, `"remote"` is the other person.
**Python**
```python
# List recent calls
calls = identity.list_calls(limit=10, offset=0)
for call in calls:
print(call.id, call.direction, call.remote_phone_number, call.status)
# Fetch transcript for a specific call
segments = identity.list_transcripts(calls[0].id)
for t in segments:
print(f"[{t.party}] {t.text}") # party: "local" (your agent) or "remote" (caller)
# Filter to only what the other person said
for t in identity.list_transcripts(calls[0].id):
if t.party == "remote":
print(t.text)
```
**TypeScript**
```typescript
// List recent calls
const calls = await identity.listCalls({ limit: 10, offset: 0 });
for (const c of calls) {
console.log(c.id, c.direction, c.remotePhoneNumber, c.status);
}
// Fetch transcript for a specific call
const segments = await identity.listTranscripts(calls[0].id);
for (const t of segments) {
console.log(`[${t.party}] ${t.text}`); // party: "local" (your agent) or "remote" (caller)
}
// Filter to only what the other person said
const remoteOnly = segments.filter(t => t.party === "remote");
for (const t of remoteOnly) console.log(t.text);
```
**CLI**
```bash
# List recent calls
inkbox phone calls -i my-agent --limit 10
# Fetch transcript for a specific call
inkbox phone transcripts call_abc123 -i my-agent
# Search transcripts for keywords
inkbox phone search-transcripts -i my-agent -q "refund" --party remote
```
## Reading across calls
To review an agent's recent call history — for example, to build a summary or audit log — iterate calls and fetch their transcripts together.
**Python**
```python
for call in identity.list_calls(limit=10):
segments = identity.list_transcripts(call.id)
if not segments:
continue
print(f"\n--- Call {call.id} ({call.direction}) ---")
for t in segments:
print(f" [{t.party:6}] {t.text}")
```
**TypeScript**
```typescript
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}`);
}
}
```
**CLI**
```bash
# List recent calls and fetch transcripts for each
inkbox phone calls -i my-agent --limit 10
inkbox phone transcripts call_abc123 -i my-agent
```
## Post-call handover
Rather than polling for finished calls, you can subscribe an agent identity to the `call.ended` webhook and be handed each call as it wraps up. It's a fire-and-forget, replayable delivery — you get it even when you never held the live call WebSocket.
The payload carries the terminated call (status, `duration_seconds`, and the resolved contact / agent-identity matches). When the call has transcribed turns it also inlines an abridged transcript so a webhook consumer can act immediately; every payload includes a `transcript_url` pointing at [`GET /phone/calls/{id}/transcripts`](/docs/api/phone/transcripts) — the authoritative verbatim record, readable with an API key or from the [Inkbox Console](https://inkbox.ai/console). For [Inkbox Voice AI](/docs/capabilities/phone/hosted-call-agent) calls the same event also delivers the `outcome` and the action items the agent recorded. See [Webhooks](/docs/webhooks#subscribing-to-call-lifecycle-events) for the subscription and handler shapes.
## Text messages (SMS/MMS)
Agents can send and receive SMS/MMS text messages on their phone numbers, including 1:1 conversations and beta group MMS conversations through the same API surface. Inbound MMS includes media attachments returned as presigned URLs with a 1-hour expiry.
**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.
Numbers on Inkbox's default 10DLC campaign are capped at **100 recipient sends per rolling 24-hour window**. A 3-recipient group message counts as 3 recipient sends. [Register your own 10DLC brand and campaign](/docs/capabilities/phone/10dlc) to lift the cap. A newly provisioned local number takes around 10-15 minutes for its 10DLC campaign to propagate, and sends during that window return `409 sender_sms_pending`. Recipients also have to opt in by texting `START` to any of your numbers before you can message them; see the [Send text endpoint](/docs/api/phone/texts) for the full opt-in / opt-out behavior.
**Python**
```python
# Send a 1:1 text
sent = identity.send_text(to="+15551234567", text="Your order has shipped.")
print(sent.id, sent.delivery_status, sent.conversation_id)
# Send beta group MMS with optional media
group = identity.send_text(
to=["+15551234567", "+12125550199"],
text="The appointment moved to 3:30.",
media_urls=["https://example.com/updated-calendar.png"],
)
print(group.conversation_id)
for r in group.recipients or []:
print(r.recipient_phone_number, r.delivery_status)
# Reply to an existing conversation by UUID
reply = identity.send_text(
conversation_id=group.conversation_id,
text="Following up in the same conversation.",
)
# List text messages
texts = identity.list_texts(limit=20, offset=0)
for t in texts:
print(t.id, t.direction, t.conversation_id, t.remote_phone_number, t.text)
# Filter to unread only
unread = identity.list_texts(is_read=False)
# Get a single text message
text = identity.get_text("text-uuid")
print(text.type) # "sms" or "mms"
if text.media: # MMS media attachments
for m in text.media:
print(m.content_type, m.size, m.url)
# List conversation summaries, including group conversations
convos = identity.list_text_conversations(limit=20, include_groups=True)
for c in convos:
print(c.id, c.participants, c.is_group, c.latest_has_media, c.latest_text)
# Get messages in a 1:1 conversation by remote number
msgs = identity.get_text_conversation("+15551234567", limit=50)
# Get messages in a group conversation by UUID
group_msgs = identity.get_text_conversation(group.conversation_id, limit=50)
# Mark a text as read
identity.mark_text_read("text-uuid")
# Mark all messages in a conversation as read
result = identity.mark_text_conversation_read("+15551234567")
print(result["updated_count"])
# Group conversations are marked by UUID
identity.mark_text_conversation_read(group.conversation_id)
# Org-level: search
results = inkbox.texts.search(identity.phone_number.id, q="invoice", limit=20)
```
**TypeScript**
```typescript
// Send a 1:1 text
const sent = await identity.sendText({ to: "+15551234567", text: "Your order has shipped." });
console.log(sent.id, sent.deliveryStatus, sent.conversationId);
// Send beta group MMS with optional media
const group = await identity.sendText({
to: ["+15551234567", "+12125550199"],
text: "The appointment moved to 3:30.",
mediaUrls: ["https://example.com/updated-calendar.png"],
});
console.log(group.conversationId);
for (const r of group.recipients ?? []) {
console.log(r.recipientPhoneNumber, r.deliveryStatus);
}
// Reply to an existing conversation by UUID
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, offset: 0 });
for (const t of texts) {
console.log(t.id, t.direction, t.conversationId, t.remotePhoneNumber, t.text);
}
// Filter to unread only
const unread = await identity.listTexts({ isRead: false });
// Get a single text message
const fetched = await identity.getText("text-uuid");
console.log(fetched.type); // "sms" or "mms"
if (fetched.media) { // MMS media attachments
for (const m of fetched.media) {
console.log(m.contentType, m.size, m.url);
}
}
// List conversation summaries, including group conversations
const convos = await identity.listTextConversations({ limit: 20, includeGroups: true });
for (const c of convos) {
console.log(c.id, c.participants, c.isGroup, c.latestHasMedia, c.latestText);
}
// Get messages in a 1:1 conversation by remote number
const msgs = await identity.getTextConversation("+15551234567", { limit: 50 });
// Get messages in a group conversation by UUID
const groupMsgs = await identity.getTextConversation(group.conversationId!, { limit: 50 });
// Mark a text as read
await identity.markTextRead("text-uuid");
// Mark all messages in a conversation as read
const result = await identity.markTextConversationRead("+15551234567");
console.log(result.updatedCount);
// Group conversations are marked by UUID
await identity.markTextConversationRead(group.conversationId!);
// Org-level: search
const results = await inkbox.texts.search(identity.phoneNumber!.id, { q: "invoice", limit: 20 });
```
**CLI**
```bash
# Send a 1:1 text
inkbox text send -i my-agent --to +15551234567 --text "Your order has shipped."
# Send beta group MMS with optional media
inkbox text send -i my-agent \
--to +15551234567,+12125550199 \
--text "The appointment moved to 3:30." \
--media-url https://example.com/updated-calendar.png
# Reply to an existing conversation by UUID
inkbox text send -i my-agent \
--conversation-id e0bc45d2-7478-4b3b-9b7c-cfc73f1ba201 \
--text "Following up in the same conversation."
# List text messages
inkbox text list -i my-agent --limit 20
# Get a single text message
inkbox text get text-uuid -i my-agent
# List conversation summaries, including group conversations
inkbox text conversations -i my-agent --include-groups
# Get messages in a 1:1 conversation
inkbox text conversation +15551234567 -i my-agent
# Get messages in a group conversation
inkbox text conversation e0bc45d2-7478-4b3b-9b7c-cfc73f1ba201 -i my-agent
# Search texts
inkbox text search -i my-agent -q "invoice"
# Mark a text as read
inkbox text mark-read text-uuid -i my-agent
# Mark all messages in a conversation as read
inkbox text mark-conversation-read +15551234567 -i my-agent
# Mark all messages in a group conversation as read
inkbox text mark-conversation-read e0bc45d2-7478-4b3b-9b7c-cfc73f1ba201 -i my-agent
```
### SMS opt-ins
Inkbox tracks consent per-recipient under your org and updates the registry automatically when recipients text `START` / `STOP`. Reading the consent state is open to any admin caller, so you can audit who has opted in, build an "opted-out" view, or pre-check before sending.
Writing consent directly — when you captured it through your own channel (a signup form, an in-product flow, a paper waiver) — is restricted to organizations on their own active, customer-managed [10DLC campaign](/docs/capabilities/phone/10dlc). Orgs on the Inkbox-default campaign share consent state with everyone else on that campaign, so writes from them return `409 customer_campaign_required`. See the [SMS opt-ins reference](/docs/api/phone/sms-opt-ins) for the full schema and error shapes.
**Python**
```python
from inkbox import SmsOptInStatus
# List the org's consent rows (newest-updated first)
rows = inkbox.sms_opt_ins.list(limit=50)
for r in rows:
print(r.receiver_number, r.status, r.source)
# Filter to opted-out only
opted_out = inkbox.sms_opt_ins.list(status=SmsOptInStatus.OPTED_OUT)
# Look up one recipient — 404 if no row exists
row = inkbox.sms_opt_ins.get("+15551234567")
print(row.status, row.opted_in_at, row.opted_out_at)
# Record consent captured outside of STOP/START (your own channel)
inkbox.sms_opt_ins.opt_in("+15551234567")
# Honor an opt-out collected outside of inbound STOP
inkbox.sms_opt_ins.opt_out("+15551234567")
```
**TypeScript**
```typescript
import { SmsOptInStatus } from "@inkbox/sdk";
// List the org's consent rows (newest-updated first)
const rows = await inkbox.smsOptIns.list({ limit: 50 });
for (const r of rows) {
console.log(r.receiverNumber, r.status, r.source);
}
// Filter to opted-out only
const optedOut = await inkbox.smsOptIns.list({ status: SmsOptInStatus.OPTED_OUT });
// Look up one recipient — 404 if no row exists
const row = await inkbox.smsOptIns.get("+15551234567");
console.log(row.status, row.optedInAt, row.optedOutAt);
// Record consent captured outside of STOP/START (your own channel)
await inkbox.smsOptIns.optIn("+15551234567");
// Honor an opt-out collected outside of inbound STOP
await inkbox.smsOptIns.optOut("+15551234567");
```
**CLI**
```bash
# List the org's consent rows
inkbox sms-opt-in list --limit 50
# Filter to opted-out only
inkbox sms-opt-in list --status opted_out
# Look up one recipient — 404 if no row exists
inkbox sms-opt-in get +15551234567
# Record consent captured outside of STOP/START (your own channel)
inkbox sms-opt-in opt-in +15551234567
# Honor an opt-out collected outside of inbound STOP
inkbox sms-opt-in opt-out +15551234567
```
## Filtering inbound calls and texts
Keep unwanted callers and senders from reaching your agents. Inkbox combines a **mode** — `whitelist` or `blacklist` — set on the **agent identity**, with a list of **contact rules** scoped to that identity, to decide whether inbound calls and texts are delivered.
Each identity has a `phone_filter_mode` field with two values:
- **`blacklist` (default).** Everything is delivered _unless_ a `block` rule matches the caller or sender.
- **`whitelist`.** Nothing is delivered _unless_ an `allow` rule matches the caller or sender.
Phone rules match on `exact_number` — an E.164 phone number. Each rule has an `action`: `allow` or `block`.
`phone_filter_mode` and phone contact rules only apply to an identity that has a phone number. While the identity has no number, setting `phone_filter_mode` or creating a rule returns `422`, and listing rules returns an empty list — assign a number first.
Most agents start in `blacklist` mode: accept everyone, add explicit blocks for individual bad actors. Switch to `whitelist` when you want the opposite — locked down by default, with a known allowlist.
**Python**
```python
# Switch an identity to whitelist mode (admin-only; identity must have a phone number)
inkbox.get_identity("my-agent").update(phone_filter_mode="whitelist")
# Back to blacklist (the default)
inkbox.get_identity("my-agent").update(phone_filter_mode="blacklist")
# Block an individual caller or sender
inkbox.phone_identity_contact_rules.create(
"my-agent",
action="block",
match_target="+14155550100",
)
# In whitelist mode: allow a trusted contact
inkbox.phone_identity_contact_rules.create(
"my-agent",
action="allow",
match_target="+14445556789",
)
# Change a rule's action
inkbox.phone_identity_contact_rules.update(
"my-agent", rule_id, action="block",
)
# Rules on one identity
phone_rules = inkbox.phone_identity_contact_rules.list("my-agent")
# Org-wide rules (admin-only) — handy for compliance reviews
all_phone = inkbox.phone_identity_contact_rules.list_all(action="block")
```
**TypeScript**
```typescript
// Switch an identity to whitelist mode (admin-only; identity must have a phone number)
await (await inkbox.getIdentity("my-agent")).update({ phoneFilterMode: "whitelist" });
// Back to blacklist (the default)
await (await inkbox.getIdentity("my-agent")).update({ phoneFilterMode: "blacklist" });
// Block an individual caller or sender
await inkbox.phoneIdentityContactRules.create("my-agent", {
action: "block",
matchTarget: "+14155550100",
});
// In whitelist mode: allow a trusted contact
await inkbox.phoneIdentityContactRules.create("my-agent", {
action: "allow",
matchTarget: "+14445556789",
});
// Change a rule's action
await inkbox.phoneIdentityContactRules.update("my-agent", ruleId, { action: "block" });
// Rules on one identity
const phoneRules = await inkbox.phoneIdentityContactRules.list("my-agent");
// Org-wide rules (admin-only) — handy for compliance reviews
const allPhone = await inkbox.phoneIdentityContactRules.listAll({ action: "block" });
```
**CLI**
```bash
# Switch an identity to whitelist mode
inkbox identity update my-agent --phone-filter-mode whitelist
# Block a caller or sender
inkbox identity phone-rules create my-agent \
--action block --match-target +14155550100
# Allow a caller (useful in whitelist mode)
inkbox identity phone-rules create my-agent \
--action allow --match-target +14445556789
# List rules on one identity
inkbox identity phone-rules list my-agent
```
### Reviewing blocked calls and texts
When an inbound call or text matches a `block` rule (or default-blocks under `whitelist` mode), the row is still persisted with `is_blocked=true` for audit. Blocked calls are rejected before connecting, and blocked texts do not fire incoming text webhooks. **Identity-scoped (agent) API keys never see those rows** — the listings, conversation summaries, search results, and conversation threads return only non-blocked rows for them.
**[Admin-scoped API keys](/docs/api-keys) and users in the [Inkbox Console](https://inkbox.ai/console) see everything by default.** Each row carries `is_blocked` so a UI can render a "Blocked" badge inline. To page through just the blocked rows — e.g. for an admin "Blocked" folder in your console — pass `is_blocked=true` to the listing endpoints. Pass `is_blocked=false` to keep an admin search or conversation summary clean of blocked spam.
**Python**
```python
# Admin-side blocked listing for one phone number
blocked_calls = inkbox.calls.list(phone_number.id, is_blocked=True)
blocked_texts = inkbox.texts.list(phone_number.id, is_blocked=True)
# Conversation summaries with blocked-only counterparties hidden,
# and previews/ordering computed from non-blocked rows only
clean_convos = inkbox.texts.list_conversations(phone_number.id, is_blocked=False)
# Search the blocked folder
spam_hits = inkbox.texts.search(phone_number.id, q="crypto", is_blocked=True)
# Each row carries is_blocked so the UI can tag it
for t in inkbox.texts.list(phone_number.id):
print(t.remote_phone_number, t.text, "BLOCKED" if t.is_blocked else "")
```
**TypeScript**
```typescript
// Admin-side blocked listing for one phone number
const blockedCalls = await inkbox.calls.list(phoneNumber.id, { isBlocked: true });
const blockedTexts = await inkbox.texts.list(phoneNumber.id, { isBlocked: true });
// Conversation summaries with blocked-only counterparties hidden,
// and previews/ordering computed from non-blocked rows only
const cleanConvos = await inkbox.texts.listConversations(phoneNumber.id, {
isBlocked: false,
});
// Search the blocked folder
const spamHits = await inkbox.texts.search(phoneNumber.id, {
q: "crypto",
isBlocked: true,
});
// Each row carries isBlocked so the UI can tag it
const texts = await inkbox.texts.list(phoneNumber.id);
for (const t of texts) {
console.log(t.remotePhoneNumber, t.text, t.isBlocked ? "BLOCKED" : "");
}
```
## Org-level phone numbers
Most call operations go through the identity, but managing the number resource itself — provisioning, configuring how inbound calls are routed, or searching transcripts across all calls on a number — happens through `inkbox.phone_numbers`. SMS/MMS event delivery is configured separately — see the [Webhooks guide](/docs/webhooks) for attaching a subscription to a phone number.
**Python**
```python
# List all phone numbers in the organisation
numbers = inkbox.phone_numbers.list()
# Get a specific phone number by ID
number = inkbox.phone_numbers.get("phone-number-uuid")
# Provision a new number (optionally request a specific US state)
number = inkbox.phone_numbers.provision(agent_handle="my-agent")
ny_number = inkbox.phone_numbers.provision(agent_handle="my-agent", state="NY")
# Configure how inbound calls are routed
inkbox.phone_numbers.update(
number.id,
incoming_call_action="webhook",
incoming_call_webhook_url="https://example.com/calls",
)
# Or auto-accept and stream audio to your agent
inkbox.phone_numbers.update(
number.id,
incoming_call_action="auto_accept",
client_websocket_url="wss://example.com/ws",
)
# Full-text search across all transcripts on a number
hits = inkbox.phone_numbers.search_transcripts(number.id, q="refund", party="remote")
for t in hits:
print(f"[{t.party}] {t.text}")
# Release a number when it's no longer needed
inkbox.phone_numbers.release(number.id)
```
**TypeScript**
```typescript
// 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 (optionally request a specific US state)
const num = await inkbox.phoneNumbers.provision({ agentHandle: "my-agent" });
const nyNum = await inkbox.phoneNumbers.provision({ agentHandle: "my-agent", state: "NY" });
// Configure how inbound calls are routed
await inkbox.phoneNumbers.update(num.id, {
incomingCallAction: "webhook",
incomingCallWebhookUrl: "https://example.com/calls",
});
// Or auto-accept and stream audio to your agent
await inkbox.phoneNumbers.update(num.id, {
incomingCallAction: "auto_accept",
clientWebsocketUrl: "wss://example.com/ws",
});
// Full-text search across all transcripts on a number
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 when it's no longer needed
await inkbox.phoneNumbers.release(num.id);
```
**CLI**
```bash
# List all phone numbers in the organisation
inkbox number list
# Get a specific phone number by ID
inkbox number get phone-number-uuid
# Provision a new number (optionally request a specific US state)
inkbox number provision --handle my-agent
inkbox number provision --handle my-agent --state NY
# Configure how inbound calls are routed
inkbox number update phone-number-uuid \
--incoming-call-action webhook \
--incoming-call-webhook-url "https://example.com/calls"
# Or auto-accept and stream audio to your agent
inkbox number update phone-number-uuid \
--incoming-call-action auto_accept \
--client-websocket-url "wss://example.com/ws"
# Search transcripts is available via the identity
inkbox phone search-transcripts -i my-agent -q "refund" --party remote
# Release a number when it's no longer needed
inkbox number release phone-number-uuid
```
---
---
# Inkbox Voice AI
description: Let Inkbox Voice AI answer and place your agent's phone calls with zero setup — no WebSocket server, no webhook required, and a structured post-call package when the call ends
---
# Inkbox Voice AI
Handling a phone call normally means running a WebSocket server: Inkbox dials or answers, connects to your `client_websocket_url`, and streams audio both ways (see [Media Stream](/docs/api/phone/media-stream)). **Inkbox Voice AI** removes the socket entirely. Inkbox runs an opinionated realtime voice agent on the platform side — it answers (or places) the call, knows who it's talking to, can work with the communication history its authority mode allows, records action items, and hands your agent a structured package when the call ends.
**Zero setup:** provision a number, flip one setting, and the number answers. No code, no socket, and no webhook required — the [`call.ended` webhook](/docs/webhooks#subscribing-to-call-lifecycle-events) is optional output, never required input.
There are exactly two ways to put a brain on a call:
- **Inkbox Voice AI** — zero setup, run by Inkbox, opinionated, with a fixed built-in toolset. This page.
- **Bring your own** — full control over the audio via `client_websocket_url` and the [media stream](/docs/api/phone/media-stream). Everything documented elsewhere in the Phone docs.
Pick per call (outbound), or per identity or per number (inbound). The two tiers never mix on a single call.
The Inkbox Voice AI surface is available in the API, the SDKs, and the CLI from **SDK 0.4.22**. Authority modes, voicemail-detection control, and safe tool activity require **SDK 0.5.8** or later.
## Answering calls
Set the identity's [incoming-call action](/docs/api/phone/incoming-call-action) to `hosted_agent`. It's the only answering action with zero prerequisites — no WebSocket URL, no webhook URL:
**Python**
```python
identity.set_incoming_call_action(incoming_call_action="hosted_agent")
```
**TypeScript**
```typescript
await identity.setIncomingCallAction({ incomingCallAction: "hosted_agent" });
```
**CLI**
```bash
inkbox phone incoming-action hosted_agent -i my-agent
```
The same value is accepted when configuring a single number (`PATCH /phone/numbers/{phone_number_id}`, or `inkbox number update --incoming-call-action hosted_agent`). The number-level `PATCH` merges: any stored `client_websocket_url` or `incoming_call_webhook_url` is kept but ignored while the action is `hosted_agent`. The identity-level action set (the snippet above) **replaces the whole inbound-call config** — omitted URLs are cleared, so if you later switch back to `auto_accept` or `webhook`, supply the URL again in that call.
Inbound protections run before the agent picks up: [contact rules](/docs/api/phone/contact-rules) and usage quotas apply exactly as they do for every other action — the voice agent only answers calls that would have been deliverable anyway.
## Placing calls
Give the agent an errand: place a call with `mode="hosted_agent"` and a plain-language `reason` describing what the call is for.
**Python**
```python
call = identity.place_call(
to_number="+15551234567",
mode="hosted_agent",
reason="Call the dental office and book a cleaning next week, mornings preferred.",
)
print(call.id, call.mode, call.status)
```
**TypeScript**
```typescript
const call = await identity.placeCall({
toNumber: "+15551234567",
mode: "hosted_agent",
reason: "Call the dental office and book a cleaning next week, mornings preferred.",
});
console.log(call.id, call.mode, call.status);
```
**CLI**
```bash
inkbox phone call -i my-agent \
--to +15551234567 \
--hosted \
--reason "Call the dental office and book a cleaning next week, mornings preferred."
```
The `reason` becomes the agent's task brief for the call: it introduces itself, works the errand, records what happened as post-call action items, and ends the call politely. A failed errand is never silent — whatever happens, you get the [post-call package](#the-post-call-package).
Outbound calls detect voicemail by default and end the call when it is detected. Disable that behavior when the agent should leave a message:
**Python**
```python
from inkbox import VoicemailDetection
call = identity.place_call(
to_number="+15555550123",
mode="hosted_agent",
reason="Confirm the appointment, and leave the details in a voicemail if needed.",
voicemail_detection=VoicemailDetection.DISABLED,
)
```
**TypeScript**
```typescript
import { CallMode, VoicemailDetection } from "@inkbox/sdk";
const call = await identity.placeCall({
toNumber: "+15555550123",
mode: CallMode.HOSTED_AGENT,
reason: "Confirm the appointment, and leave the details in a voicemail if needed.",
voicemailDetection: VoicemailDetection.DISABLED,
});
```
**Rust**
```
use inkbox::phone::{
CallOrigin, HostedCallPlacementOptions, VoicemailDetection,
};
let call = identity.place_hosted_call_with_options(
"+15555550123",
CallOrigin::DedicatedNumber,
"Confirm the appointment, and leave the details in a voicemail if needed.",
&HostedCallPlacementOptions {
authority_mode: None,
voicemail_detection: Some(VoicemailDetection::Disabled),
},
)?;
```
**CLI**
```bash
inkbox phone call -i my-agent \
--to +15555550123 \
--hosted \
--reason "Confirm the appointment, and leave the details in a voicemail if needed." \
--no-voicemail-detection
```
Omit the setting to retain the default. Voicemail detection is independent of the call's authority mode and works for both Voice AI and client-driven calls.
Two shape rules, enforced with a `422`: `mode="hosted_agent"` requires a non-empty `reason` (up to 2,000 characters) and must not carry a `client_websocket_url`; `reason` is only valid with `mode="hosted_agent"`. Everything else about placing a call — origination, caller ID rules, rate limits — is unchanged; see the [place call reference](/docs/api/phone/calls#place-call). When Voice AI capacity is momentarily saturated, the API returns `503 hosted_agent_at_capacity`; retry shortly.
`mode` works with any `origination`, so the voice agent can call over a dedicated number or the identity's iMessage line alike.
Outbound Voice AI calls inherit the identity's saved authority mode. You can
override one call when it needs narrower or wider access; see
[Choosing an authority mode](#choosing-an-authority-mode).
## Configuring the agent
Each identity carries an optional Voice AI configuration: a `voice`, a `model`, and free-form `instructions` (up to 8,000 characters) that steer how the agent behaves on that identity's calls. All three are nullable — a `null` field means the platform default applies, and the defaults are sensible, so most identities need no configuration at all.
**Python**
```python
# Read the current config
config = identity.get_hosted_agent_config()
print(config.effective_voice, config.effective_model, config.authority_mode)
# Replace it (full-replace: omitted fields reset to the platform default)
identity.set_hosted_agent_config(
instructions="You answer for Blue Harbor Dental. Be warm and brief. "
"Never quote prices; offer to text the pricing page instead.",
)
```
**TypeScript**
```typescript
// Read the current config
const config = await identity.getHostedAgentConfig();
console.log(config.effectiveVoice, config.effectiveModel, config.authorityMode);
// Replace it (full-replace: omitted fields reset to the platform default)
await identity.setHostedAgentConfig({
instructions:
"You answer for Blue Harbor Dental. Be warm and brief. " +
"Never quote prices; offer to text the pricing page instead.",
});
```
**Rust**
```
// Read the current config
let config = identity.hosted_agent_config()?;
println!("{} {}", config.effective_voice, config.effective_model);
// Replace it (full-replace: omitted fields reset to the platform default)
identity.set_hosted_agent_config(
None,
None,
Some("You answer for Blue Harbor Dental. Be warm and brief."),
)?;
```
**CLI**
```bash
# Read the current config
inkbox phone hosted-agent get -i my-agent
# Replace it (full-replace: omitted flags reset to the platform default)
inkbox phone hosted-agent set -i my-agent \
--instructions "You answer for Blue Harbor Dental. Be warm and brief."
```
Under the hood this is `GET`/`PUT /phone/hosted-agent-config`. Reads also report the current `authority_mode`. Writes are **full-replace** for voice, model, and instructions: every omitted or `null` field resets to the platform default, while the authority mode is unchanged. Identity-scoped API keys resolve their own identity; with an admin API key, or when managing from the [Inkbox Console](https://inkbox.ai/console), pass `agent_identity_id`.
Custom instructions are layered on top of everything the agent already knows, so they can steer tone, boundaries, and priorities without you restating the basics. The agent treats them as standing orders from you, applied on every call — with one carve-out: on an outbound errand, the call's `reason` takes precedence if the two conflict, so a standing quirk can't hijack the task the call was placed for.
## Choosing an authority mode
Every Voice AI call has one of two authority modes:
| Mode | Scope |
| :--------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `contact_scoped` | The agent can work only with the current caller or outbound recipient and that contact's history. |
| `yolo` | The agent can work across the identity's contacts, email, SMS, iMessage, and historical calls, including records and recipients unrelated to the current call. |
Use `contact_scoped` when the call should stay focused on the person on the line. Use `yolo` when the caller needs the voice agent to act as a general assistant for the identity — for example, to look up another contact, review a different conversation, or send a follow-up to someone else.
### Set the identity's default
New identities start with `contact_scoped`. You can change the saved mode from
the Inkbox Console, or send a request with an admin API key. Future incoming
Voice AI calls and outbound Voice AI calls without an override inherit this
setting:
**Python**
```python
from inkbox import HostedAgentAuthorityMode
config = identity.set_hosted_agent_authority_mode(
HostedAgentAuthorityMode.YOLO,
)
```
**TypeScript**
```typescript
import { HostedAgentAuthorityMode } from "@inkbox/sdk";
const config = await identity.setHostedAgentAuthorityMode({
authorityMode: HostedAgentAuthorityMode.YOLO,
});
```
**Rust**
```
use inkbox::phone::HostedAgentAuthorityMode;
let config = identity.set_hosted_agent_authority_mode(
HostedAgentAuthorityMode::Yolo,
)?;
```
**CLI**
```bash
inkbox phone hosted-agent authority-mode yolo -i my-agent
```
**cURL**
```bash
curl -X PUT "https://inkbox.ai/api/v1/phone/hosted-agent-config/authority-mode" \
-H "X-API-Key: YOUR_ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"agent_identity_id": "d4e5f6a7-b8c9-0123-def0-456789012345",
"authority_mode": "yolo"
}'
```
The body accepts exactly `agent_identity_id` and `authority_mode`. A successful response returns the identity's current Voice AI configuration, including the new `authority_mode`. An invalid identity or mode returns `422`, and an unknown identity returns `404`.
This endpoint is separate from the full-replace Voice AI configuration above, so changing voice, model, or instructions does not change the authority mode.
### Choose the mode for one outbound call
Omit `hosted_agent_authority_mode` to inherit the identity's saved mode. To
narrow one call, explicitly set `contact_scoped`; any credential that can place
the call can do this. To request `yolo`, include it as an override:
**Python**
```python
from inkbox import HostedAgentAuthorityMode
call = identity.place_call(
to_number="+15555550123",
mode="hosted_agent",
hosted_agent_authority_mode=HostedAgentAuthorityMode.YOLO,
reason="Review today's open conversations and summarize anything urgent.",
)
```
**TypeScript**
```typescript
import {
CallMode,
HostedAgentAuthorityMode,
} from "@inkbox/sdk";
const call = await identity.placeCall({
toNumber: "+15555550123",
mode: CallMode.HOSTED_AGENT,
hostedAgentAuthorityMode: HostedAgentAuthorityMode.YOLO,
reason: "Review today's open conversations and summarize anything urgent.",
});
```
**Rust**
```
use inkbox::phone::{CallOrigin, HostedAgentAuthorityMode};
let call = identity.place_hosted_call_with_authority(
"+15555550123",
CallOrigin::DedicatedNumber,
"Review today's open conversations and summarize anything urgent.",
HostedAgentAuthorityMode::Yolo,
)?;
```
**CLI**
```bash
inkbox phone call -i my-agent \
--to +15555550123 \
--hosted \
--authority-mode yolo \
--reason "Review today's open conversations and summarize anything urgent."
```
**REST**
```json
{
"from_number": "+15555550100",
"to_number": "+15555550123",
"mode": "hosted_agent",
"hosted_agent_authority_mode": "yolo",
"reason": "Review today's open conversations and summarize anything urgent."
}
```
An explicit `yolo` reuses a saved `yolo` grant with any credential that can
place the call. If the identity's saved mode is `contact_scoped`, requesting
`yolo` widens access and requires an admin API key or the Inkbox Console.
`yolo` is valid only when `mode` is `hosted_agent`. Each
[call object](/docs/api/phone/calls#call-object) records the authority mode that
applied to that call.
## What the agent knows
Every Voice AI session is briefed at answer time with the current call:
- **Its own identity** — the agent's display name, handle, email address, and which line the call is on.
- **Who it's talking to** — the counterparty's [contact card](/docs/api/contacts) (name, company, notes, known numbers and emails), resolved by the number on the call. Unknown callers work fine; the agent simply greets them neutrally.
- **Their connection state** — whether the caller is opted in to SMS, and whether they're connected over iMessage — so the agent offers follow-ups on channels that will actually work, and can walk them through connecting where they aren't.
- **The local time** — the call clock runs in the counterparty's timezone, inferred from the phone number (stated as approximate), so "tomorrow morning" means what the caller means.
- **The task brief** — on outbound Voice AI calls, your `reason`.
With `contact_scoped`, the agent can read the identity's history with that person across calls, SMS, iMessage, and email. With `yolo`, it can also look across the identity's other contacts, conversations, messages, and historical calls as the task requires.
## What the agent can do
Voice AI uses a built-in capability set. Sends go through the same pipeline as any other Inkbox send: SMS opt-outs, contact rules, and delivery tracking all apply exactly as if your own code had sent the message.
On every call, the agent can:
- **Record work for your agent** — register, refine, or withdraw post-call action items during the call; the open items become the [post-call package](#the-post-call-package).
- **Answer "what is Inkbox?"** — it can consult the public Inkbox docs and offer to text a relevant link rather than reading URLs aloud.
- **Check live state and end the call** — re-check the caller's connection state mid-call (e.g. to confirm "your message just came through"), and hang up once the caller signals they're done.
With `contact_scoped`, it can:
- **Recall history with the person on the call** — past calls and transcripts, SMS, iMessage, and email threads, plus keyword search across that history.
- **Text, iMessage, or email that person mid-call** — using the identity's corresponding channel.
With `yolo`, it can also:
- **Work with contacts across the identity** — find, create, and update contact records.
- **Read and send across communication channels** — search and read email, SMS, and iMessage conversations and send to an explicit recipient accepted by that channel.
- **Review historical calls across the identity** — list past calls with any counterparty and read their transcripts.
### Tool activity
Open a `yolo` call in the Inkbox Console to see each tool's running, completed, or failed state. The same safe, paginated activity is available from [`GET /phone/calls/{call_id}/tool-invocations`](/docs/api/phone/calls#list-voice-ai-tool-activity) and from `calls.tool_invocations()` in Python, `calls.toolInvocations()` in TypeScript, or `calls().tool_invocations()` in Rust.
The activity view omits tool arguments and returns only a small result summary. Once a write is accepted, Inkbox continues it even if the call ends; a briefly running item can therefore finish after hangup.
**Python**
```python
page = identity.list_tool_invocations(
call_id=str(call.id),
limit=50,
offset=0,
)
```
**TypeScript**
```typescript
const page = await identity.listToolInvocations(call.id, {
limit: 50,
offset: 0,
});
```
**Rust**
```
let page = identity.list_tool_invocations(
&call.call.id.to_string(),
50,
0,
)?;
```
**CLI**
```bash
inkbox phone tool-activity CALL_ID --limit 50 --offset 0
```
### What it can't do
`contact_scoped` cannot read or contact anyone other than the person on the call. `yolo` widens that data scope, but it does not bypass channel availability, consent, contact rules, delivery behavior, or usage limits.
A Voice AI agent cannot place another phone call while it is already on a call. It can review historical calls and transcripts, but not the active call's transcript through the historical-call interface.
It also doesn't run custom tools. If your agent needs to hit your systems mid-call, that's the bring-your-own tier: take the call over `client_websocket_url` and drive it yourself. For everything that can wait until the call ends, post-call action items cover it — the voice agent records the work, and your agent executes it from the `call.ended` delivery.
## The post-call package
Every Voice AI call ends in a single [`call.ended`](/docs/api/phone/webhooks#call-ended-webhook) delivery carrying the whole story: the call record (now with `mode` and `reason`), the transcript, an `outcome`, and the recorded `post_call_action_items` — atomically, in one event. A Voice AI call reports back on every terminal state, inbound and placed alike — even when nobody picked up, `outcome` says what happened — so an errand can't fail silently.
| `outcome` | Meaning |
| :---------- | :------------------------------------------------------------------------------------------------------------------------------------------ |
| `completed` | The call connected and ran to a normal end. A call that reaches voicemail also reports `completed` — the transcript shows what happened |
| `no_answer` | The call was never answered — an outbound call rang out, or an inbound caller hung up before the agent picked up |
| `declined` | The call was rejected, or the line was busy |
| `failed` | The Voice AI session hit an error and the call was torn down, or the call could not be completed at all — e.g. an undeliverable destination |
`outcome` is `null` on `client_websocket` calls — it describes Voice AI sessions only, and it rides the `call.ended` payload as `data.outcome`.
Action items arrive as an ordered list on the same event, as `data.post_call_action_items`:
```json
[
{
"id": "9f8e7d6c-5b4a-3210-fedc-ba9876543210",
"seq": 1,
"action": "Confirm Tuesday 9:30am cleaning with Dr. Chen's office",
"details": "They pencilled it in; front desk asked for a confirmation call Monday.",
"status": "open"
}
]
```
Each item carries a `seq` (its 1-based order), an `action` title, optional `details`, and a `status` of `"open"`. Actions the agent canceled mid-call are dropped — only open items ride the payload. The `call.ended` event is the one atomic delivery of the whole package (`outcome` lives only there); like every webhook it carries a stable ID and is replayable, so a missed delivery can be re-fetched rather than lost. The open action items are additionally readable straight off the [call object](/docs/api/phone/calls#call-object) as `post_call_action_items`, so they can be looked up any time without replaying the event.
## Watching and stopping a live call
You keep two levers while a Voice AI call is in progress:
- **Live view** — transcript segments land while the call runs. Poll [`GET /phone/calls/{call_id}/transcripts`](/docs/api/phone/transcripts), or watch the call live in the [Inkbox Console](https://inkbox.ai/console).
- **Kill switch** — [`POST /phone/calls/{call_id}/hangup`](/docs/api/phone/calls#hang-up-call) ends any live call from the outside, Voice AI or not. Teardown runs the normal lifecycle, so you still receive the full post-call package.
## Related
- [Place call](/docs/api/phone/calls#place-call) — the `mode`, `reason`, `hosted_agent_authority_mode`, and `voicemail_detection` request fields
- [Incoming calls](/docs/api/phone/incoming-call-action) — the `hosted_agent` action
- [Call-ended webhook](/docs/api/phone/webhooks#call-ended-webhook) — the full payload, including `outcome` and `post_call_action_items`
- [Webhooks guide](/docs/webhooks#subscribing-to-call-lifecycle-events) — subscribing and handler examples
---
---
# 10DLC registration
description: Register your brand and campaign so your local US numbers can send SMS at higher throughput under your own identity
---
# 10DLC registration
10DLC ("10-digit long code") is the US carrier program that decides which businesses are allowed to send SMS from local phone numbers, and how fast. Every business that sends application-to-person (A2P) SMS in the US has to be on file with [The Campaign Registry](https://www.campaignregistry.com/) (TCR) as a **brand** running one or more **campaigns**. Carriers then use that registration to set rate limits and to decide whether to deliver, throttle, or block your messages.
When you provision a local number on Inkbox, it's automatically attached to **Inkbox's own brand and campaign** so it can send right away — but on tight rate limits. To unlock your own throughput, register your own brand and campaign, then route your numbers through them. The whole flow is configurable from the [Console](https://inkbox.ai/console/10dlc).
Running your own campaign costs **$20/month** on top of your identity fees — see [pricing](/pricing). The charge applies only while your campaign is approved by carriers: you pay nothing while registration is under review, and the charge stops if approval lapses or you delete the campaign.
## Do you need to register?
| You should… | If… |
| --- | --- |
| **Stay on the Inkbox campaign** | You're prototyping, sending low volume from a single number, or only need a few notification-style messages per day. |
| **Register your own brand + campaign** | You're going to production, you want recipients to see your business associated with the sender, you've been hitting `429 sender_rate_limited`, or you need throughput beyond what the Inkbox campaign allows. |
## Before you start
Have these on hand before you open the form. You can save a draft and come back, but the registry will reject submissions that don't match official records exactly.
**For the brand** (your business identity):
- Legal entity type — private/public for-profit, non-profit, government, or sole proprietor.
- Legal company name **as filed with the IRS** and your **EIN** (or the equivalent if non-US).
- Registered business address (street, city, state, ZIP, country).
- Business contact email and phone.
- Public website URL.
- Your industry vertical (e.g. retail, healthcare, financial, communication).
- For public companies: stock ticker and exchange.
- For sole proprietors: a mobile phone you can receive an SMS verification code on.
**For the campaign** (what your messages are for):
- A use case (e.g. customer care, account notification, 2FA, marketing, mixed).
- A short description of what your agents will text recipients about.
- A short description of how recipients opt in (the "message flow").
- **Four sample messages** that look like the real thing your agents will send — TCR reviewers compare submitted samples against actual traffic.
- The exact opt-in / opt-out / help keywords and auto-replies you want to use. Defaults are pre-filled (`STOP`, `HELP`, etc.) and follow [CTIA messaging best practices](https://api.ctia.org/wp-content/uploads/2019/07/190719-CTIA-Messaging-Principles-and-Best-Practices-FINAL.pdf) — leave them as-is unless you have a reason to change them.
- Public URLs for your **privacy policy** and **terms & conditions**. Both pages must mention SMS, that message and data rates may apply, and that recipients can opt out by replying STOP.
## Step 1: Register your brand
1. Open the [Console → Configuration → 10DLC tab](https://inkbox.ai/console/10dlc).
2. Click **Register brand** and fill out the form using the details above.
3. Submit. The brand status will move to **Verifying** while the registry runs identity checks.
4. Most brands reach **Verified** within a few hours; some take 1–2 business days. Sole-proprietor brands need an extra step (see below).
### Sole proprietors: SMS verification
If you registered as a sole proprietor, the brand stays in **Verifying** until you confirm the mobile number you registered. After submission, the page shows a **Verify mobile phone** button. Click it, the registry texts a 6-digit PIN to that number, paste the PIN back into the dialog. Once verified, the brand flips to **Verified** and you can move on.
### Brand statuses
| Status | What it means |
| --- | --- |
| **Verifying** | Submitted; the registry is running identity checks. |
| **Verified** | Identity confirmed. You can register a campaign. |
| **Vetted** | Passed enhanced vetting — your campaign will be eligible for higher SMS throughput tiers. |
| **Failed** | The registry could not match your business. Delete the brand, correct any mismatches against your IRS/registry filings, and re-register. |
## Step 2: Register your campaign
You can only register a campaign once your brand is **Verified** (or **Vetted**).
1. Back on the [10DLC tab](https://inkbox.ai/console/10dlc), click **Register campaign**.
2. Pick the use case that matches what your agents will actually send. Picking the wrong use case is the most common cause of carrier rejection.
3. Fill in the description, sample messages, and opt-in/keywords/auto-replies. Provide your privacy policy and terms & conditions URLs.
4. Submit. The campaign goes through two reviews — the registry first, then each major US mobile carrier.
### Campaign statuses
| Status | What it means |
| --- | --- |
| **TCR review** | The Campaign Registry is reviewing the campaign metadata. Usually clears in minutes. |
| **Awaiting carriers** | Registry approved; queued for carrier review. |
| **Carrier review** | At least one carrier is still reviewing. Usually clears within a few business days. |
| **Approved** | Live with all carriers — you can route your numbers through it. |
| **Rejected by registry** / **Carrier rejected** | The submission did not pass. The rejection reason is shown on the campaign card. Delete the campaign, fix the issue, and re-register. Common causes: sample messages don't match the use case, missing opt-in language, privacy policy or terms URLs that don't mention SMS. |
For a deeper explanation of how carriers score campaigns and assign throughput tiers, [The Campaign Registry's resource center](https://www.campaignregistry.com/resources/) is the authoritative public reference.
## Step 3: Route your numbers through your campaign
Once the campaign is **Approved**, switch your local numbers over.
1. On the [10DLC tab](https://inkbox.ai/console/10dlc), find the **Default SMS campaign** dropdown at the top of the page.
2. Switch it from **Inkbox** to **Your 10DLC profile**.
3. All of your numbers move onto your campaign within a few minutes.
You can flip back to **Inkbox** at any time — your campaign stays registered.
### Per-number propagation window
A number takes about **10–15 minutes** to fully attach to a new campaign downstream. Sends during that window return `409 sender_sms_pending`. The Console flags affected numbers with a **SMS pending** badge until they're ready. See [Texts](/docs/api/phone/texts) for the full error reference.
## Rate limits
A "recipient send" is one outbound recipient on `POST /api/v1/phone/numbers/{phone_number_id}/texts`. A 3-recipient group message counts as 3 recipient sends.
| Profile | Per-number limit |
| --- | --- |
| **Inkbox** (default for new numbers) | **100 recipient sends per rolling 24 hours**, per sender number. |
| **Your 10DLC profile** (Approved) | No Inkbox-side per-number cap — you're on the carrier-assigned throughput tier for your campaign. Tier depends on use case, brand vetting, and carrier policy. |
Group MMS and MMS sends from 10DLC local numbers are beta. Even with an approved campaign and ready sender, some carriers may reject group chats or MMS from 10DLC numbers.
Every send response — success or failure — includes `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` so you can pace traffic. On a `429`, you'll also get `Retry-After` (seconds).
The error body when you hit the shared-profile limit:
```json
{
"error": "sender_rate_limited",
"message": "This phone number has reached the 24-hour SMS recipient-send limit (100). Register your own 10DLC brand and campaign to lift this limit."
}
```
If your traffic is bursty, slow it to under one send every few minutes per number on the Inkbox campaign, or register your own.
## Deleting and starting over
The Console doesn't support editing a submitted brand or campaign — to change anything, delete and re-register.
- **Delete a brand** to start over from scratch. Deleting a brand cascades to its campaign and reverts every local number to the Inkbox campaign. The Console asks you to type the brand name to confirm.
- **Delete a campaign** if it was rejected and you want to resubmit with different metadata. The Console asks you to type `DELETE CAMPAIGN` to confirm.
## Troubleshooting
The registry is matching the legal name + EIN you submitted against IRS records. The most common cause of a delay is a mismatch — even a trailing "Inc." or a wrong EIN digit fails the match.
If yours is past 24 hours, **email [hello@inkbox.ai](mailto:hello@inkbox.ai)** with your organization name and the brand display name — we can pull the registry-side state for you and tell you exactly what's wrong. In most cases the fix is to delete the brand, correct the mismatched field against your most recent IRS filing, and re-register.
The rejection reason is shown on the campaign card on the 10DLC tab, verbatim from the carrier. The most common reasons:
- **Sample messages don't match the use case.** If you picked "customer care" but your samples look promotional, carriers reject. Either change the use case or rewrite the samples to fit.
- **Missing opt-in language.** Your opt-in flow has to be described in the **message flow** field, *and* your privacy policy / terms pages have to mention SMS, message and data rates, and STOP-to-opt-out.
- **Public URLs aren't reachable.** Make sure the privacy policy and terms URLs you submitted are live and don't require login.
Editing a submitted campaign isn't supported — delete it and re-register with the corrected metadata.
That's expected. Routing changes take ~10–15 minutes per number to propagate downstream. Wait for the **SMS pending** badge in the Console to clear and try again.
Each Inkbox organization can have one brand and one campaign at a time today. If you need to send for materially different use cases (e.g. one-time-password traffic alongside marketing), email [hello@inkbox.ai](mailto:hello@inkbox.ai).
Vendor-neutral references:
- [The Campaign Registry — Resources](https://www.campaignregistry.com/resources/) — the registry that operates 10DLC; explains brand vetting, campaign use cases, and trust scores.
- [CTIA Messaging Principles and Best Practices](https://api.ctia.org/wp-content/uploads/2019/07/190719-CTIA-Messaging-Principles-and-Best-Practices-FINAL.pdf) — the wireless industry's published rules for what is and isn't acceptable A2P traffic.
- [Wikipedia: Application-to-person SMS](https://en.wikipedia.org/wiki/SMS#Application-to-person_(A2P)_SMS) — broader background on how A2P SMS differs from person-to-person.
---
---
# iMessage
description: Reach humans in their native messaging app through shared or dedicated numbers, send tapbacks, and filter who can reach your agent
---
# iMessage
Agents can chat with humans over iMessage — blue bubbles, tapbacks, read receipts, typing indicators, and media — through the shared **Inkbox iMessage router** or a dedicated number.
iMessage number types have distinct capabilities:
- **Outbound:** Can start new 1:1 conversations and group chats.
- **Inbound:** Can reply in existing conversations.
- **Shared:** Dynamically assigned when someone connects to your agent.
With the shared service, a human connects by texting the router. Inkbox assigns the conversation to your identity without exposing the shared number in API responses. Dedicated numbers belong to your organization and can be attached to an iMessage-enabled identity. The number's `type` — `dedicated_inbound` or `dedicated_outbound` — is the sole source of its capabilities.
**Beta:** Organization-owned iMessage routers are available upon request. They give an organization its own configurable router number for connecting humans to agents. An organization-owned router is separate from dedicated inbound and outbound numbers: it serves the organization rather than attaching to one agent. Organizations without one continue to use the shared Inkbox router.
Conversations remain the stable key in every setup. Agent-facing APIs identify threads by `conversation_id`; 1:1 conversations can also be addressed by the human's E.164 phone number. Every conversation belongs to one agent identity in your organization, and an identity-scoped API key sees only that identity's threads.
Shared and dedicated inbound numbers support 1:1 conversations. Dedicated outbound numbers also support group chats with 2-8 remote participants.
## Enabling iMessage on an identity
iMessage reachability is **opt-in per identity** and defaults to off. Enable it at create time or later:
**Python**
```python
# At create time
identity = inkbox.create_identity("my-agent", imessage_enabled=True)
# Or toggle later
identity.update(imessage_enabled=True)
print(identity.imessage_enabled, identity.imessage_filter_mode)
```
**TypeScript**
```typescript
// At create time
const identity = await inkbox.createIdentity("my-agent", { imessageEnabled: true });
// Or toggle later
await identity.update({ imessageEnabled: true });
console.log(identity.imessageEnabled, identity.imessageFilterMode);
```
**CLI**
```bash
# At create time
inkbox identity create my-agent --imessage-enabled
# Or toggle later
inkbox identity update my-agent --imessage-enabled true
```
You can also claim and attach a dedicated number while creating or updating an identity. See [Dedicated numbers](/docs/api/imessage#dedicated-numbers) and [Manage identities](/docs/api/identities/manage) for the raw REST contract.
While an identity is disabled, the router will not connect new recipients to it, sends from it are rejected, and inbound traffic for it is not delivered.
## Connecting a human to your agent
Humans connect by texting a command to the router. Resolve the router number at runtime — it can change, so never hardcode it:
**Python**
```python
router = inkbox.imessages.get_triage_number()
print(router.number) # the router's E.164 number
print(router.connect_command) # e.g. 'connect @my-agent'
```
**TypeScript**
```typescript
const router = await inkbox.imessages.getTriageNumber();
console.log(router.number); // the router's E.164 number
console.log(router.connectCommand); // e.g. 'connect @my-agent'
```
**CLI**
```bash
inkbox imessage triage-number
# number the router's E.164 number
# connectCommand e.g. 'connect @my-agent'
```
Tell your human: *text `connect @my-agent` to the router number*. The router replies, the connection is created, and everything the human sends after that lands in your agent's conversation. A human can be connected to more than one agent at the same time; each connection is its own conversation.
Here's what that looks like from the human's side — they text the connect command to the router, and the router confirms the connection and shares the agent's contact card:
## Reading and replying
Once a recipient has messaged your agent, list conversations, read messages, and reply. Replies can target the conversation by ID or the recipient by number:
**Python**
```python
# List conversations with latest-message previews
convos = identity.list_imessage_conversations(limit=20)
for c in convos:
print(c.id, c.remote_number, c.unread_count, c.latest_text)
# Read a thread
msgs = identity.list_imessages(conversation_id=convos[0].id, limit=50)
# Reply into the conversation
identity.send_imessage(
conversation_id=convos[0].id,
text="On it — give me two minutes.",
)
# Or address the connected recipient directly
identity.send_imessage(to="+15555550123", text="Done!")
```
**TypeScript**
```typescript
// List conversations with latest-message previews
const convos = await identity.listIMessageConversations({ limit: 20 });
for (const c of convos) {
console.log(c.id, c.remoteNumber, c.unreadCount, c.latestText);
}
// Read a thread
const msgs = await identity.listIMessages({ conversationId: convos[0].id, limit: 50 });
// Reply into the conversation
await identity.sendIMessage({
conversationId: convos[0].id,
text: "On it — give me two minutes.",
});
// Or address the connected recipient directly
await identity.sendIMessage({ to: "+15555550123", text: "Done!" });
```
**CLI**
```bash
# List conversations with latest-message previews
inkbox imessage conversations -i my-agent --limit 20
# Read a thread
inkbox imessage conversation -i my-agent
# Reply into the conversation
inkbox imessage send -i my-agent \
--conversation-id \
--text "On it — give me two minutes."
```
When sending to a number without an active conversation, the remediation depends on the setup. On the shared service, a `404` tells the recipient to text the connect command to the router. On a dedicated inbound number, the `404` tells them to iMessage the attached number directly. A dedicated outbound number can start a new conversation. If a previous conversation is no longer active, sends into it return `409` with the corresponding first-contact instructions. Conversation reads carry an `assignment_status` field (`"active"` or `"released"`) so you can spot a disconnect before sending, and [`GET /assignments`](/docs/api/imessage/conversations#list-connections) lists who is currently connected.
Rolling 24-hour outbound limits vary by plan. See [Pricing](/pricing) for current limits. When the cap is reached, `429` responses carry `Retry-After` and `X-RateLimit-*` headers.
## Dedicated outbound group chats
An identity with an active attached outbound iMessage number can start a group by passing a list of 2-8 distinct E.164 phone numbers to the existing send endpoint. The response's `conversation_id` is the canonical thread key; use it for every later reply into that exact group.
**Python**
```python
group = identity.send_imessage(
to=["+14155550100", "+14155550101"],
text="Planning starts at 10.",
send_style="confetti",
)
identity.send_imessage(
conversation_id=group.conversation_id,
text="I moved the start time to 10:30.",
send_style="gentle",
)
groups = identity.list_imessage_conversations(include_groups=True)
```
**TypeScript**
```typescript
const group = await identity.sendIMessage({
to: ["+14155550100", "+14155550101"],
text: "Planning starts at 10.",
sendStyle: "confetti",
});
await identity.sendIMessage({
conversationId: group.conversationId,
text: "I moved the start time to 10:30.",
sendStyle: "gentle",
});
const groups = await identity.listIMessageConversations({ includeGroups: true });
```
**CLI**
```bash
inkbox imessage send -i my-agent \
--to +14155550100,+14155550101 \
--text "Planning starts at 10." \
--send-style confetti
inkbox imessage conversations -i my-agent --include-groups
inkbox imessage send -i my-agent \
--conversation-id \
--text "I moved the start time to 10:30." \
--send-style gentle
```
Group lists are opt-in for backwards compatibility. Pass `include_groups=true` when listing iMessage conversations or messages, or fetch a known group directly by `conversation_id`. Group messages expose `sender_number`, a participant snapshot, and per-recipient delivery state.
Group creation is asynchronous. Conversations report `group_creation_status` as `creating`, `not_created`, or `ready`. While creating, the conversation and queued messages exist locally before the remote thread is established; later sends to the same conversation wait for the creation send to resolve. If the first attempt fails, its message remains visible in the same local conversation; send the next message using that `conversation_id` to retry creation. A successful retry keeps the same local conversation and establishes the group used by later sends.
Conversation membership history is best-known and append-only: newly added members may appear only after later activity, while members who leave remain in the stored history. Sends into an existing group resolve its current recipients and apply send checks to each one. There is no group-member add/remove API in V1, and changing a direct `to` list selects or creates another group instead of editing membership. After the stored history grows, an old participant subset can create a separate group; use `conversation_id` to preserve thread continuity. Read receipts, typing indicators, and MCP group sends or actions are not yet supported. Tapbacks are supported for ready groups, and expressive send styles are supported on group creation and replies.
See [Group chats](/docs/api/imessage/groups) for exact request, response, matching, and error semantics.
## Tapbacks
Agents can react to inbound messages in 1:1 and ready group conversations with `love`, `like`, `dislike`, `laugh`, `emphasize`, `question`, or `eyes` (which displays as 👀). iMessage has no concept of reacting to your own messages, so an agent can't tapback its own.
**Python**
```python
identity.send_imessage_reaction(message_id=msgs[0].id, reaction="like")
# Live tapbacks come back on message reads, oldest first
for r in msgs[0].reactions or []:
print(r.direction, r.reaction, r.custom_emoji)
```
**TypeScript**
```typescript
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);
}
```
**CLI**
```bash
inkbox imessage react -i my-agent --reaction like
```
Tapbacks follow Apple's semantics in both conversation types: **one live tapback per sender per message**. Sending a second tapback to the same message replaces your first, and when a human swaps or removes theirs, message reads reflect it. Humans can also react with any emoji — those arrive as `reaction: "custom"` with the emoji in `custom_emoji`. Arbitrary custom-emoji tapbacks are receive-only; outbound sends accept the classic six plus the named `eyes` value, not a literal emoji. Group reactions have `assignment_id: null` and use `remote_number` to identify the participant.
## Read receipts, typing, and media
Round out the native feel in 1:1 conversations: send a read receipt when your agent has read the thread and show a typing indicator while it works. Media also works in group sends, but group read receipts and typing indicators are not supported.
**Python**
```python
# Read receipt — the human sees "Read" under their message
identity.mark_imessage_conversation_read(convos[0].id)
# Typing indicator while the agent prepares a reply
identity.send_imessage_typing(convos[0].id)
# Upload media (max 10 MiB), then send the returned URL
upload = identity.upload_imessage_media(
content=open("chart.png", "rb").read(),
filename="chart.png",
content_type="image/png",
)
identity.send_imessage(
conversation_id=convos[0].id,
media_urls=[upload.media_url],
)
```
**TypeScript**
```typescript
// Read receipt — the human sees "Read" under their message
await identity.markIMessageConversationRead(convos[0].id);
// Typing indicator while the agent prepares a reply
await identity.sendIMessageTyping(convos[0].id);
// Upload media (max 10 MiB), then send the returned URL
const upload = await identity.uploadIMessageMedia({
content: await readFile("chart.png"),
filename: "chart.png",
contentType: "image/png",
});
await identity.sendIMessage({
conversationId: convos[0].id,
mediaUrls: [upload.mediaUrl],
});
```
**CLI**
```bash
# Read receipt
inkbox imessage mark-conversation-read -i my-agent
# Typing indicator
inkbox imessage typing -i my-agent
# Upload media, then send the returned URL
inkbox imessage upload-media ./chart.png -i my-agent --content-type image/png
inkbox imessage send -i my-agent \
--conversation-id \
--media-url
```
Expressive **send styles** work on 1:1 messages and dedicated-outbound group creation or replies. Pass `send_style` with values like `slam`, `confetti`, `lasers`, or `invisible` (invisible ink); styles can also accompany media. See the [Messages reference](/docs/api/imessage/messages#send-styles) for the full list.
## Filtering who can reach your agent
iMessage contact rules work just like [phone](/docs/capabilities/phone#filtering-inbound-calls-and-texts) and mail rules — all three are scoped to the **agent identity** and interpreted against a per-identity `filter_mode`. The identity remains the policy owner whether it uses the shared service or a dedicated number.
Each identity has an iMessage `filter_mode`:
- **`blacklist`** (default) — everyone can reach the agent except numbers with an active `block` rule.
- **`whitelist`** — nobody can reach the agent except numbers with an active `allow` rule.
**Python**
```python
# Block one number
rule = inkbox.imessage_contact_rules.create(
"my-agent", action="block", match_target="+15555550999",
)
# Review the identity's rules
for r in inkbox.imessage_contact_rules.list("my-agent"):
print(r.action, r.match_target, r.status)
# Flip to whitelist mode (admin API key required)
identity.update(imessage_filter_mode="whitelist")
```
**TypeScript**
```typescript
// Block one number
const rule = await inkbox.imessageContactRules.create("my-agent", {
action: "block",
matchTarget: "+15555550999",
});
// Review the identity's rules
for (const r of await inkbox.imessageContactRules.list("my-agent")) {
console.log(r.action, r.matchTarget, r.status);
}
// Flip to whitelist mode (admin API key required)
await identity.update({ imessageFilterMode: "whitelist" });
```
**CLI**
```bash
# Block one number
inkbox imessage contact-rule create -i my-agent \
--action block --match-target +15555550999
# Review the identity's rules
inkbox imessage contact-rule list -i my-agent
# Flip to whitelist mode (admin API key required)
inkbox identity update my-agent --imessage-filter-mode whitelist
```
Blocked inbound messages are stored for review but never reach the agent: identity-scoped API keys never see them, no webhooks fire for them, and outbound sends to a blocked number return `403` before anything leaves Inkbox. Admin API keys and the [Inkbox Console](https://inkbox.ai/console) can audit blocked rows with `is_blocked=true` filters. Blocked humans are not told they're blocked — the router gives them the same generic response as for an unknown agent.
## Reacting to events in real time
Inbound messages and tapbacks are delivered through [webhook subscriptions](/docs/api/webhooks/subscriptions) owned by the **agent identity**, regardless of whether it uses the shared service or a dedicated number. The same subscription can also carry the outbound delivery-lifecycle events — `imessage.sent`, `imessage.delivered`, and `imessage.delivery_failed` — so your agent knows when a reply actually landed:
**Python**
```python
inkbox.webhooks.subscriptions.create(
agent_identity_id=identity.id,
url="https://yourapp.example.com/webhooks/inkbox",
event_types=["imessage.received", "imessage.reaction_received"],
)
```
**TypeScript**
```typescript
await inkbox.webhooks.subscriptions.create({
agentIdentityId: identity.id,
url: "https://yourapp.example.com/webhooks/inkbox",
eventTypes: ["imessage.received", "imessage.reaction_received"],
});
```
**CLI**
```bash
inkbox webhook subscription create \
--agent-identity-id \
--url https://yourapp.example.com/webhooks/inkbox \
--event-type imessage.received \
--event-type imessage.reaction_received
```
See [iMessage webhooks](/docs/api/imessage/webhooks) for payload shapes and [Signing keys](/docs/signing-keys) for signature verification.
## How messages are delivered
Inkbox always tries iMessage first. If a recipient isn't reachable over iMessage, delivery can fall back to SMS — the message's `service` field reports the transport actually used (`"imessage"`, `"sms"`, or `"rcs"`), and `was_downgraded` is set when a fallback happened.
## Next steps
- [iMessage API reference](/docs/api/imessage)
- [Webhook subscriptions](/docs/api/webhooks/subscriptions)
- [Identities](/docs/capabilities/identities)
---
---
# Tunnels
description: Give your agent a stable public URL using the Inkbox SDK
---
# Tunnels
A tunnel gives your agent a stable public hostname — `my-agent.inkboxwire.com` — that routes inbound HTTP, WebSocket, and raw-TCP traffic from third parties to your agent over a single persistent connection. Your agent stays behind whatever NAT, firewall, or laptop Wi-Fi it happens to be running on; no public IP, no firewall hole, no reverse proxy needed on your end.
> Use the Python or TypeScript SDK to bring tunnels online from your agent.
## Provisioning a tunnel
Tunnels are provisioned automatically when you [create an identity](/docs/capabilities/identities) — every identity owns exactly one tunnel, whose name is the identity's `agent_handle`. To opt into passthrough TLS at create time, pass a nested `tunnel` body to `create_identity()`. See the [Manage identities reference](/docs/api/identities/manage) for the full request shape.
Handles, and therefore tunnel names, must be globally unique across all Inkbox customers. They are 3–63 characters, lowercase letters / digits / hyphens, must start and end with a letter or digit, and may not contain consecutive hyphens.
## Connecting your agent
The data-plane `connect()` helper opens the persistent agent connection and forwards inbound traffic to wherever you point it. The simplest setup forwards traffic to a local HTTP server you're already running. Authentication uses the same `INKBOX_API_KEY` the rest of the SDK uses — there is no per-tunnel secret on disk.
**Python**
```python
# Bring the tunnel online and forward to your local server.
listener = inkbox.tunnels.connect(
tunnel_id=identity.tunnel.id,
forward_to="http://localhost:8080",
)
print(listener.public_url) # https://my-agent.inkboxwire.com
listener.wait() # blocks until SIGINT / SIGTERM
```
**TypeScript**
```typescript
// connect() lives on a Node-only subpath because it pulls in
// node:http2, node:tls, and node:fs. The main package entry stays
// browser-safe.
const listener = await connect(inkbox, {
tunnelId: identity.tunnel.id,
forwardTo: "http://localhost:8080",
});
console.log(listener.publicUrl); // https://my-agent.inkboxwire.com
await listener.wait(); // resolves on clean close, throws on fatal
```
The TypeScript listener installs SIGINT and SIGTERM handlers automatically only when the parent process has none at construction time, so it stays out of the way of host processes that own their own shutdown — pass `installSignalHandlers: false` (or `true` to attach alongside) to override.
To rotate the credential used for the data-plane connection, rotate the underlying API key — see [API keys](/docs/api-keys).
## In-process handlers
If you don't want to run a separate local HTTP server, hand `connect()` a handler function and it will run inside your agent process.
**Python**
```python
# Any ASGI app works — pass the app callable as forward_to.
# Example with FastAPI:
from fastapi import FastAPI
app = FastAPI()
@app.post("/webhook")
async def webhook(payload: dict):
return {"ok": True, "received": payload}
listener = inkbox.tunnels.connect(tunnel_id=identity.tunnel.id, forward_to=app)
listener.wait()
```
**TypeScript**
```typescript
// Pass a Fetch-API handler: (req, ctx) => Response | Promise.
const listener = await connect(inkbox, {
tunnelId: identity.tunnel.id,
handler: async (req, ctx) => {
const url = new URL(req.url);
if (url.pathname === "/webhook" && req.method === "POST") {
const payload = await req.json();
return Response.json({ ok: true, received: payload });
}
return new Response("not found", { status: 404 });
},
});
await listener.wait();
```
The `ctx` argument on the TypeScript handler exposes `forwardedForIp`, `sniHost`, an `AbortSignal` that fires when the runtime's deadline expires, and a read-only `envelope` escape hatch for metadata not surfaced on the typed fields.
## WebSockets
Inbound WebSocket upgrades are bridged transparently to your handler.
**Python**
```python
# ASGI handles HTTP and WebSocket through the same app callable.
# Use any ASGI framework that supports WS (FastAPI, Starlette, etc.).
from fastapi import FastAPI, WebSocket
app = FastAPI()
@app.websocket("/ws")
async def ws(socket: WebSocket):
await socket.accept()
while True:
msg = await socket.receive_text()
await socket.send_text(f"echo: {msg}")
listener = inkbox.tunnels.connect(tunnel_id=identity.tunnel.id, forward_to=app)
listener.wait()
```
**TypeScript**
```typescript
// In TypeScript, WebSockets are a separate handler from HTTP.
// You still need an HTTP path (forwardTo or handler) alongside.
const listener = await connect(inkbox, {
tunnelId: identity.tunnel.id,
forwardTo: "http://localhost:8080", // or handler: ...
wsHandler: async (ws) => {
await ws.accept();
for await (const msg of ws) {
await ws.send(`echo: ${msg}`);
}
},
});
await listener.wait();
```
## Sync vs async lifecycle
`connect()` returns a `TunnelListener`. In Python, pick **one** lifecycle pair and stick with it — sync (`wait` / `close`) and async (`serve_forever` / `aclose`) are mutually exclusive on a given listener. In TypeScript, the listener is always async-driven; `wait()` is the canonical way to block until shutdown.
> jane-doe.vcf
# Bulk-import from a .vcf file
inkbox contacts import contacts.vcf
```
## Related
- [Contacts API reference](/docs/api/contacts)
- [Memory and correspondence reference](/docs/api/contacts/memory)
- [vCard import/export reference](/docs/api/contacts/vcards)
---
---
# Notes
description: Record and search shared notes across your agents using the Inkbox SDK and CLI
---
# Notes
Notes are free-form org-scoped text — a `title` and a `body` — that your agents (and humans) can save, search, and share. Use them for anything that needs to outlive a single conversation: meeting summaries, research snippets, reminders to other agents, customer context that a future turn should see.
**Visibility rules.** Human users see every note in the org. Agents see notes only when granted. An agent that creates a note is auto-granted to that note; peers need an explicit grant from an admin or human user.
## Creating a note
`body` is required and can be up to 100 000 characters. `title` is optional but handy for search results and UI lists.
**Python**
```python
note = inkbox.notes.create(
title="Customer onboarding call — Acme",
body=(
"Jane Doe walked through requirements. Pain points: data export, "
"SSO for their vendor portal. Next step: schedule demo for 2026-05-03."
),
)
print(note.id, note.created_by)
```
**TypeScript**
```typescript
const note = await inkbox.notes.create({
title: "Customer onboarding call — Acme",
body:
"Jane Doe walked through requirements. Pain points: data export, " +
"SSO for their vendor portal. Next step: schedule demo for 2026-05-03.",
});
console.log(note.id, note.createdBy);
```
**CLI**
```bash
inkbox notes create \
--title "Customer onboarding call — Acme" \
--body "Jane Doe walked through requirements..."
```
## Full-text search
`notes.list` accepts a `q` parameter that runs a full-text search over `title + body`. Combine it with `identity_id` to filter to notes visible to a specific agent, or `order` to sort by most-recent activity vs creation time.
**Python**
```python
# Recent notes first
notes = inkbox.notes.list(limit=20)
# Full-text search
hits = inkbox.notes.list(q="onboarding")
for n in hits:
print(n.title, "-", n.created_at)
# Notes granted to a specific agent
sales_notes = inkbox.notes.list(identity_id=sales_agent.id)
# Oldest created first
oldest = inkbox.notes.list(order="created", limit=20)
```
**TypeScript**
```typescript
// Recent notes first
const notes = await inkbox.notes.list({ limit: 20 });
// Full-text search
const hits = await inkbox.notes.list({ q: "onboarding" });
for (const n of hits) {
console.log(n.title, "-", n.createdAt);
}
// Notes granted to a specific agent
const salesNotes = await inkbox.notes.list({ identityId: salesAgent.id });
// Oldest created first
const oldest = await inkbox.notes.list({ order: "created", limit: 20 });
```
**CLI**
```bash
# Recent notes
inkbox notes list --limit 20
# Full-text search
inkbox notes list --q onboarding
# Filter to notes an agent can see
inkbox notes list --identity
```
## Updating and deleting
Updates use JSON-merge-patch: omit a field to leave it unchanged, send `null` on `title` to clear it. `body` cannot be cleared — send a new string to replace it, or delete the whole note.
**Python**
```python
# Replace just the title
note = inkbox.notes.update(note.id, title="Customer onboarding — Acme (follow-up sent)")
# Clear the title
note = inkbox.notes.update(note.id, title=None)
# Replace the body
note = inkbox.notes.update(note.id, body="Updated minutes from the 2026-05-03 demo.")
# Delete the note
inkbox.notes.delete(note.id)
```
**TypeScript**
```typescript
// Replace just the title
let n = await inkbox.notes.update(note.id, {
title: "Customer onboarding — Acme (follow-up sent)",
});
// Clear the title
n = await inkbox.notes.update(note.id, { title: null });
// Replace the body
n = await inkbox.notes.update(note.id, {
body: "Updated minutes from the 2026-05-03 demo.",
});
// Delete the note
await inkbox.notes.delete(note.id);
```
**CLI**
```bash
# Update title
inkbox notes update --title "Customer onboarding — Acme"
# Replace body
inkbox notes update --body "Updated minutes..."
# Delete
inkbox notes delete
```
## Controlling visibility
Agents that create a note are auto-granted to it. Sharing to other agents requires an [admin-scoped API key](/docs/api-keys) or a user in the [Inkbox Console](https://inkbox.ai/console).
**Python**
```python
# Share a note with another agent (admin-scoped API key required)
inkbox.notes.access.grant(note.id, identity_id=support_agent.id)
# List who has access
rules = inkbox.notes.access.list(note.id)
for r in rules:
print(r.identity_id, r.created_at)
# Revoke access
inkbox.notes.access.revoke(note.id, identity_id=support_agent.id)
```
**TypeScript**
```typescript
// Share a note with another agent (admin-scoped API key required)
await inkbox.notes.access.grant(note.id, supportAgent.id);
// List who has access
const rules = await inkbox.notes.access.list(note.id);
for (const r of rules) {
console.log(r.identityId, r.createdAt);
}
// Revoke access
await inkbox.notes.access.revoke(note.id, supportAgent.id);
```
**CLI**
```bash
# Share a note (admin operation)
inkbox notes access grant
# List who has access
inkbox notes access list
# Revoke
inkbox notes access revoke
```
## Related
- [Notes API reference](/docs/api/notes)
- [Access control reference](/docs/api/notes/access)
---
---
# Vault
description: Store and manage encrypted credentials for your AI agents using the Inkbox SDK and CLI
---
# Vault
The vault is a zero-knowledge encrypted credential store for your organization. Store API keys, login credentials, SSH keys, and other secrets: Inkbox never sees the plaintext. All encryption and decryption happens client-side in the SDK or console using your vault key.
## How it works
Every secret stored in the vault is encrypted with your organization's encryption key before it leaves the SDK. The server only ever sees ciphertext. To read secrets, you unlock the vault with your vault key, and the SDK or console decrypts everything locally.
Two keys are involved:
| Key | Purpose |
| :--- | :--- |
| `INKBOX_API_KEY` | Authenticates API requests (sent to server) |
| `INKBOX_VAULT_KEY` | Unlocks the vault for client-side decryption (never sent to server) |
Each key resolves from the SDK/CLI argument, then its environment variable, then a `~/.inkbox/config` file (`api_key = ...` / `vault_key = ...`) — handy for background or agent processes that don't inherit your shell's env.
## Secret types
Each secret has a type that determines its payload structure:
| Type | Fields | Use case |
| :--- | :--- | :--- |
| `login` | `password`, `username`, `email`, `url`, `notes`, `totp` | Website or service logins (with optional TOTP) |
| `api_key` | `api_key`, `endpoint`, `notes` | API keys and tokens |
| `key_pair` | `access_key`, `secret_key`, `endpoint`, `notes` | AWS-style key pairs |
| `ssh_key` | `private_key`, `public_key`, `fingerprint`, `passphrase`, `notes` | SSH keys |
| `other` | `data`, `notes` | Freeform secrets |
## Unlocking the vault
Before you can read or write secrets, unlock the vault with your vault key. The SDK validates the key, fetches all encrypted secrets, and decrypts them locally.
**Python**
```python
import os
from inkbox import Inkbox
with Inkbox(api_key=os.environ["INKBOX_API_KEY"]) as inkbox:
unlocked = inkbox.vault.unlock(os.environ["INKBOX_VAULT_KEY"])
print(f"Unlocked {len(unlocked.secrets)} secrets")
```
**TypeScript**
```typescript
import { Inkbox } from "@inkbox/sdk";
const inkbox = new Inkbox({ apiKey: process.env.INKBOX_API_KEY! });
const unlocked = await inkbox.vault.unlock(process.env.INKBOX_VAULT_KEY!);
console.log(`Unlocked ${unlocked.secrets.length} secrets`);
```
**CLI**
```bash
# Set both keys as environment variables
export INKBOX_API_KEY=ApiKey_...
export INKBOX_VAULT_KEY=VaultKey_...
# Or pass the vault key as a flag
inkbox --vault-key VaultKey_... vault get secret_abc123
```
## Initializing the vault
Initialize a vault once per organization. This creates the vault, sets the primary vault key, and generates four recovery codes. Store the recovery codes securely when they are returned.
**Python**
```python
result = inkbox.vault.initialize(
"My-Str0ng-Vault-Key!",
"org_2abc123def456",
)
print(result.vault_id)
print(result.recovery_codes)
```
**TypeScript**
```typescript
const result = await inkbox.vault.initialize(
"My-Str0ng-Vault-Key!",
"org_2abc123def456",
);
console.log(result.vaultId);
console.log(result.recoveryCodes);
```
**CLI**
```bash
inkbox vault init \
--organization-id org_2abc123def456 \
--vault-key "My-Str0ng-Vault-Key!"
```
## Creating secrets
Once unlocked, create secrets by specifying a name and a typed payload. The SDK encrypts the payload before sending it to the server.
**Python**
```python
# Store an API key
unlocked.create_secret(
name="LLM Production",
description="Production API key for the primary model provider",
payload={
"type": "api_key",
"api_key": "sk-proj-abc123...",
"endpoint": "https://api.example.com/v1",
"notes": "Rate limit: 10k RPM",
},
)
# Store a login credential
unlocked.create_secret(
name="Dashboard Login",
payload={
"type": "login",
"username": "agent@example.com",
"password": "s3cret!",
"url": "https://dashboard.example.com",
},
)
```
**TypeScript**
```typescript
// Store an API key
await unlocked.createSecret({
name: "LLM Production",
description: "Production API key for the primary model provider",
payload: {
type: "api_key",
apiKey: "sk-proj-abc123...",
endpoint: "https://api.example.com/v1",
notes: "Rate limit: 10k RPM",
},
});
// Store a login credential
await unlocked.createSecret({
name: "Dashboard Login",
payload: {
type: "login",
username: "agent@example.com",
password: "s3cret!",
url: "https://dashboard.example.com",
},
});
```
**CLI**
```bash
# Store an API key
inkbox vault create \
--name "LLM Production" \
--description "Production API key for the primary model provider" \
--type api_key \
--key "sk-proj-abc123..." \
--endpoint "https://api.example.com/v1" \
--notes "Rate limit: 10k RPM"
# Store a login credential
inkbox vault create \
--name "Dashboard Login" \
--type login \
--username "agent@example.com" \
--password "s3cret!" \
--url "https://dashboard.example.com"
```
## Reading secrets
Access all decrypted secrets via the `secrets` property, or fetch a specific one by ID.
**Python**
```python
# List all secrets
for secret in unlocked.secrets:
print(secret.name, secret.secret_type, secret.payload)
# Get a specific secret by ID
secret = unlocked.get_secret(secret_id)
print(secret.payload)
```
**TypeScript**
```typescript
// List all secrets
for (const secret of unlocked.secrets) {
console.log(secret.name, secret.secretType, secret.payload);
}
// Get a specific secret by ID
const secret = await unlocked.getSecret(secretId);
console.log(secret.payload);
```
**CLI**
```bash
# List all secrets (metadata only)
inkbox vault secrets
# Filter by type
inkbox vault secrets --type api_key
# Get and decrypt a specific secret
inkbox vault get secret_abc123
```
## Updating and deleting secrets
Update a secret's name, description, or payload. Delete secrets when they're no longer needed.
**Python**
```python
# Update a secret's payload
unlocked.update_secret(
secret_id,
payload={
"type": "api_key",
"api_key": "sk-proj-newkey456...",
"endpoint": "https://api.example.com/v1",
},
)
# Delete a secret
unlocked.delete_secret(secret_id)
```
**TypeScript**
```typescript
// Update a secret's payload
await unlocked.updateSecret(secretId, {
payload: {
type: "api_key",
apiKey: "sk-proj-newkey456...",
endpoint: "https://api.example.com/v1",
},
});
// Delete a secret
await unlocked.deleteSecret(secretId);
```
**CLI**
```bash
# Update is available via the SDK or API
# Delete a secret
inkbox vault delete secret_abc123
```
## Storing logins with TOTP
Login secrets can include a TOTP configuration for two-factor authentication. Use `parse_totp_uri` to parse a standard `otpauth://` URI (the same format used by Google Authenticator, Authy, etc.) into a TOTP config, then attach it to the login payload.
**Python**
```python
from inkbox.vault.totp import parse_totp_uri
from inkbox.vault.types import LoginPayload
totp_uri = "otpauth://totp/GitHub:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=GitHub"
unlocked.create_secret(
name="GitHub",
payload=LoginPayload(
username="user@example.com",
password="s3cret!",
url="https://github.com/login",
totp=parse_totp_uri(totp_uri),
),
)
```
**TypeScript**
```typescript
import { parseTotpUri } from "@inkbox/sdk";
const totpUri = "otpauth://totp/GitHub:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=GitHub";
await unlocked.createSecret({
name: "GitHub",
payload: {
type: "login",
username: "user@example.com",
password: "s3cret!",
url: "https://github.com/login",
totp: parseTotpUri(totpUri),
},
});
```
**CLI**
```bash
inkbox vault create \
--name "GitHub" \
--type login \
--username "user@example.com" \
--password "s3cret!" \
--url "https://github.com/login" \
--totp-uri "otpauth://totp/GitHub:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=GitHub"
```
You can also build a TOTP config manually instead of parsing a URI:
**Python**
```python
from inkbox.vault.totp import TOTPConfig
totp = TOTPConfig(
secret="JBSWY3DPEHPK3PXP",
algorithm="sha1", # sha1, sha256, or sha512
digits=6, # 6 or 8
period=30, # 30 or 60 seconds
issuer="GitHub",
account_name="user@example.com",
)
```
**TypeScript**
```typescript
import type { TOTPConfig } from "@inkbox/sdk";
const totp: TOTPConfig = {
secret: "JBSWY3DPEHPK3PXP",
algorithm: "sha1", // sha1, sha256, or sha512
digits: 6, // 6 or 8
period: 30, // 30 or 60 seconds
issuer: "GitHub",
accountName: "user@example.com",
};
```
**CLI**
```bash
# The CLI accepts TOTP via the otpauth:// URI format with --totp-uri
# Manual TOTP config building is available via the SDK
```
## Generating TOTP codes
Once a login secret has a TOTP config, generate the current one-time code with `get_totp_code`. The code, expiry window, and seconds remaining are returned.
**Python**
```python
code = unlocked.get_totp_code(secret_id)
print(code.code) # e.g. "482901"
print(code.seconds_remaining) # seconds until this code expires
```
**TypeScript**
```typescript
const code = await unlocked.getTotpCode(secretId);
console.log(code.code); // e.g. "482901"
console.log(code.secondsRemaining); // seconds until this code expires
```
**CLI**
```bash
# Generate a TOTP code for a secret scoped to an identity
inkbox identity totp-code my-agent secret_abc123
```
The returned `TOTPCode` includes:
| Field | Type | Description |
| :--- | :--- | :--- |
| `code` | string | The current OTP code (e.g. `"482901"`) |
| `period_start` | number | Unix timestamp when this code became valid |
| `period_end` | number | Unix timestamp when this code expires |
| `seconds_remaining` | number | Seconds left until expiry |
You can also generate codes directly from a TOTP config without storing it in the vault:
**Python**
```python
from inkbox.vault.totp import generate_totp, parse_totp_uri
config = parse_totp_uri("otpauth://totp/Test?secret=JBSWY3DPEHPK3PXP")
code = generate_totp(config)
print(code.code)
```
**TypeScript**
```typescript
import { generateTotp, parseTotpUri } from "@inkbox/sdk";
const config = parseTotpUri("otpauth://totp/Test?secret=JBSWY3DPEHPK3PXP");
const code = generateTotp(config);
console.log(code.code);
```
**CLI**
```bash
# Standalone TOTP generation is available via the SDK
```
## Identity access control
Grant specific agent identities access to individual secrets. This lets you control which agents can use which credentials.
**Python**
```python
# Grant an identity access to a secret
inkbox.vault.grant_access(secret_id, identity_id)
# List access rules for a secret
rules = inkbox.vault.list_access_rules(secret_id)
for rule in rules:
print(rule.identity_id)
# Revoke access
inkbox.vault.revoke_access(secret_id, identity_id)
```
**TypeScript**
```typescript
// Grant an identity access to a secret
await inkbox.vault.grantAccess(secretId, identityId);
// List access rules for a secret
const rules = await inkbox.vault.listAccessRules(secretId);
for (const rule of rules) {
console.log(rule.identityId);
}
// Revoke access
await inkbox.vault.revokeAccess(secretId, identityId);
```
**CLI**
```bash
# Revoke an identity's access to a secret
inkbox identity revoke-access my-agent secret_abc123
# Grant access and list rules are available via the SDK or API
```
## Vault metadata
Check the vault's status and counts without unlocking it.
**Python**
```python
info = inkbox.vault.info()
print(info.status) # "active"
print(info.secret_count) # number of stored secrets
print(info.key_count) # number of vault keys
```
**TypeScript**
```typescript
const info = await inkbox.vault.info();
console.log(info.status); // "active"
console.log(info.secretCount); // number of stored secrets
console.log(info.keyCount); // number of vault keys
```
**CLI**
```bash
# Show vault info
inkbox vault info
# List vault keys
inkbox vault keys
```
## Managing vault keys
Rotate the primary vault key or revoke an existing key by auth hash. Rotating the primary key keeps the same organization encryption key and re-wraps it under the new primary vault key.
**Python**
```python
# Rotate the primary vault key using the current key
key = inkbox.vault.update_key(
"My-N3w-Vault-Key!",
current_vault_key="My-Str0ng-Vault-Key!",
)
print(key.id)
# Or rotate using a recovery code
inkbox.vault.update_key(
"My-N3w-Vault-Key!",
recovery_code="ABCD-EFGH-JKLM-NPQR-STUV-WXYZ-2345-6789",
)
# Revoke a recovery key or other existing key by auth hash
inkbox.vault.delete_key("a1b2c3d4e5f6...")
```
**TypeScript**
```typescript
// Rotate the primary vault key using the current key
const key = await inkbox.vault.updateKey({
newVaultKey: "My-N3w-Vault-Key!",
currentVaultKey: "My-Str0ng-Vault-Key!",
});
console.log(key.id);
// Or rotate using a recovery code
await inkbox.vault.updateKey({
newVaultKey: "My-N3w-Vault-Key!",
recoveryCode: "ABCD-EFGH-JKLM-NPQR-STUV-WXYZ-2345-6789",
});
// Revoke a recovery key or other existing key by auth hash
await inkbox.vault.deleteKey("a1b2c3d4e5f6...");
```
**CLI**
```bash
# Rotate the primary vault key using the current key
inkbox vault update-key \
--new-vault-key "My-N3w-Vault-Key!" \
--current-vault-key "My-Str0ng-Vault-Key!"
# Or rotate using a recovery code
inkbox vault update-key \
--new-vault-key "My-N3w-Vault-Key!" \
--recovery-code "ABCD-EFGH-JKLM-NPQR-STUV-WXYZ-2345-6789"
# Revoke a key by auth hash
inkbox vault delete-key a1b2c3d4e5f6...
```
## Deleting the vault
Delete the vault and all its keys and secrets from the Inkbox Console. This is destructive and permanently removes access to all stored secrets. After deletion, the organization can initialize a new vault.
Vault deletion is not available through the SDK or CLI. Use the [Inkbox Console](https://inkbox.ai/console) instead.
After deletion, you can initialize a new vault.
---
---
# API keys
description: Admin-scoped vs agent-scoped API keys, what each can do, and how to mint, inspect, and revoke them.
---
# API keys
API keys authenticate every request to the Inkbox API. Pass the key in the `X-API-Key` header. Each key has a fixed scope chosen at creation time. The plaintext value is returned **only once** when the key is created — store it securely; it cannot be retrieved again.
---
## Scopes
Every API key has one of two scopes. Scope is fixed at creation and cannot be changed later.
### Admin-scoped
Org-wide authority. An admin-scoped key can act on any resource in the organization and manage org-level configuration — including custom email domains, 10DLC compliance, contact rules, and note access grants.
### Agent-scoped
Bound to a single agent identity. The key can only operate as — or on resources owned by — that one agent. Agent-scoped keys are typically issued during the [agent signup](/docs/get-started/agent-signup) flow.
---
## What each scope can do
| Capability | Admin-scoped | Agent-scoped |
| :--- | :---: | :---: |
| Send and receive mail, texts, and calls | ✓ | ✓ (as the bound identity) |
| Read contacts | ✓ | ✓ (all org contacts) |
| Read notes | ✓ (all org resources) | ✓ (only what the identity is granted access to) |
| Manage [custom email domains](/docs/capabilities/email/custom-email-domains) | ✓ | — |
| Manage [10DLC compliance](/docs/capabilities/phone/10dlc) | ✓ | — |
| Manage [mail](/docs/api/mail/contact-rules) and [phone](/docs/api/phone/contact-rules) contact rules | ✓ | — |
| Grant access to [notes](/docs/api/notes/access) | ✓ | — |
| Mint new agent-scoped API keys | ✓ | — |
| Mint new admin-scoped API keys | — | — |
Endpoints that require an admin-scoped key return `403` when called with an agent-scoped key.
---
## Minting rules
Who can mint which kind of key:
| Caller | Can mint admin-scoped | Can mint agent-scoped |
| :--- | :---: | :---: |
| Console session | ✓ | ✓ |
| Admin-scoped API key | — | ✓ |
| Agent-scoped API key | — | — |
When minting from an admin-scoped API key, pass `scoped_identity_id` in the request body to bind the new key to a specific agent identity.
Scope is fixed at creation. To rotate a key, mint a new one and revoke the old one.
Agent-scoped keys can also be obtained programmatically via the [agent signup](/docs/get-started/agent-signup) flow, which mints a key bound to the newly-claimed identity.
---
## Inspect a key `GET`
```
GET /api-keys/self
```
Returns metadata for the calling key. The plaintext value is never returned again after creation.
### Response (200)
```json
{
"id": "ApiKey_8f3a2c91...",
"scoped_identity_id": null,
"status": "active",
"display_prefix": "ink_live_",
"last4": "k9X2",
"created_at": "2026-04-01T10:14:22Z",
"last_used_at": "2026-05-08T17:02:11Z",
"expires_at": null,
"revoked_at": null
}
```
`scoped_identity_id` is `null` for admin-scoped keys, or an identity ID for agent-scoped keys.
### Code examples
**cURL**
```bash
curl "https://inkbox.ai/api/v1/api-keys/self" \
-H "X-API-Key: YOUR_API_KEY"
```
**JavaScript**
```javascript
const response = await fetch("https://inkbox.ai/api/v1/api-keys/self", {
headers: { "X-API-Key": "YOUR_API_KEY" },
});
const key = await response.json();
```
**Python**
```python
import requests
response = requests.get(
"https://inkbox.ai/api/v1/api-keys/self",
headers={"X-API-Key": "YOUR_API_KEY"},
)
key = response.json()
```
---
## Update a key
Updating a key's label or description is supported from the console. Scope, status, and other fields are immutable from any caller.
---
## Revoke a key `POST`
```
POST /api-keys/self/revoke
```
Revokes the calling key. Revocation is permanent — to replace a key, mint a new one before revoking the old one. You can also revoke any key from the console.
### Response (200)
```json
{
"id": "ApiKey_8f3a2c91...",
"status": "revoked",
"revoked_at": "2026-05-08T17:30:00Z"
}
```
### Code examples
**cURL**
```bash
curl -X POST "https://inkbox.ai/api/v1/api-keys/self/revoke" \
-H "X-API-Key: YOUR_API_KEY"
```
**JavaScript**
```javascript
await fetch("https://inkbox.ai/api/v1/api-keys/self/revoke", {
method: "POST",
headers: { "X-API-Key": "YOUR_API_KEY" },
});
```
**Python**
```python
import requests
requests.post(
"https://inkbox.ai/api/v1/api-keys/self/revoke",
headers={"X-API-Key": "YOUR_API_KEY"},
)
```
---
## Choosing a scope
- Use **agent-scoped** keys for per-agent runtime credentials. Each agent gets its own key, narrowed to that identity.
- Use **admin-scoped** keys for backend orchestration: provisioning agents, configuring custom domains and 10DLC, and managing contact rules and access grants.
- **Don't ship admin-scoped keys to end-user agents.** Mint an agent-scoped key per agent instead.
---
## Related
- [Agent signup](/docs/get-started/agent-signup) — claim an agent identity and receive its initial API key
- [Identities](/docs/capabilities/identities) — agent identity model
- [Signing keys](/docs/signing-keys) — verify the authenticity of webhooks Inkbox sends to you
- [Webhooks](/docs/webhooks) — receive events from Inkbox
---
---
# Inkbox MCP server
description: Connect an MCP client to Inkbox
---
# Inkbox MCP server
The Inkbox MCP server gives compatible AI clients access to an Inkbox identity.
## Connect
Use the remote Streamable HTTP endpoint that matches your client:
| Client | Endpoint |
|---|---|
| Most MCP clients | `https://inkbox.ai/mcp` |
| ChatGPT | `https://inkbox.ai/mcp/openai` |
| Claude | `https://inkbox.ai/mcp/anthropic` |
The first two serve the same catalog and behave identically. Each endpoint has its own OAuth resource identifier, so an authorization granted to one does not carry over to another.
The Claude endpoint excludes hosted voice tools: placing and ending calls, call settings, hosted voice-agent configuration, incoming-call handling, and phone-readiness checks. Call history and transcripts remain available.
Authenticate with OAuth when prompted. Clients that support custom headers can instead use a claimed identity-scoped API key:
```text
X-API-Key: YOUR_API_KEY
```
OAuth grants the connected client full-service access to the capabilities available to the selected identity until you revoke the connection.
## What's included
- **Identity:** View the active identity and manage its profile and channels.
- **Email:** Search, read, send, reply, forward, organize, and work with attachments.
- **Messaging:** Read and send SMS, MMS, and iMessage, including supported conversation actions.
- **Calls:** Review calls and transcripts, place and end calls, and manage call settings.
- **Contacts and notes:** Search and manage contacts, conversation context, and notes.
- **Contact rules:** Review communication rules and phone readiness.
The server also provides resources for supported Inkbox records and prompts for inbox triage, communications briefings, replies, follow-ups, contact history, call preparation, conversation summaries, and notes. Available capabilities depend on the selected identity's access and the capabilities currently enabled on the server.
## Example prompts
- *Summarize unread messages across my channels and suggest next actions.*
- *Draft a reply to the latest email from a contact without sending it.*
- *Show my recent conversation history with a contact.*
## Data access and actions
The connected client can receive communication content and account data returned by the tools you authorize. It can also request actions such as sending communications, placing calls, changing records, or deleting records. Review confirmation screens before approving external or irreversible actions.
Only connect services you trust. Data delivered to a connected service is handled under that service's terms and privacy notice. Revoke the connection or its identity-scoped API key to stop future access.
For help, contact [hello@inkbox.ai](mailto:hello@inkbox.ai). See the [Privacy Policy](/privacy-policy), [Terms of Service](/terms-of-service), and [Data Protection Addendum](/dpa).
---
---
# Webhooks
description: Subscribe HTTPS endpoints to mail, phone, iMessage, and Agent2Agent events, verify signatures, and discriminate typed payloads
---
# Webhooks
Webhook delivery flows through a channel-agnostic [subscription resource](/docs/api/webhooks/subscriptions). Each subscription names one owner — a mailbox, a phone number, **or** an agent identity — one HTTPS destination URL, and a non-empty subset of the event catalog. Many subscriptions can attach to the same owner; each URL receives its own POST per event independently, so one slow receiver doesn't block delivery to the others.
Each subscription row contains events from one channel. An identity can use the
same destination URL for separate iMessage, call-lifecycle, and Agent2Agent
subscriptions as long as their event lists do not overlap.
The one exception is `phone.incoming_call`. That event is a synchronous control-plane callback — the response body decides whether Inkbox answers — so it stays on a per-number field. Configure it via `incoming_call_webhook_url` on the [phone number resource](/docs/api/phone/numbers).
## Subscribing to mail events
Pick a subset of `message.*` events to deliver to one URL. The full catalog is `message.received`, `message.sent`, `message.forwarded`, `message.delivered`, `message.bounced`, `message.failed`.
**Python**
```python
inkbox.webhooks.subscriptions.create(
mailbox_id=mailbox.id,
url="https://example.com/hook",
event_types=["message.received", "message.bounced"],
)
```
**TypeScript**
```typescript
await inkbox.webhooks.subscriptions.create({
mailboxId: mailbox.id,
url: "https://example.com/hook",
eventTypes: ["message.received", "message.bounced"],
});
```
You can attach up to 20 active subscriptions per mailbox. Each URL receives its own POST per event, so split events across receivers or fan one URL out across many mailboxes from many subscription rows.
## Phone webhooks
Phone numbers have one synchronous control-plane callback (`incoming_call_webhook_url`) for incoming calls, plus per-event subscriptions for the text lifecycle:
- **`incoming_call_webhook_url`** — receives the **flat, synchronous** inbound-call payload (no envelope). Your response (`action: "answer" | "reject"` plus optional `client_websocket_url`) decides what happens to the call. Top-level `contacts` and `agent_identities` carry the matches for the caller.
- **Text events** (`text.received`, `text.sent`, `text.delivered`, `text.delivery_failed`, `text.delivery_unconfirmed`) — subscribe to any subset via `/webhooks/subscriptions` with `phone_number_id`. Standard envelope; `data.contacts` and `data.agent_identities` carry the matches for the sender or lifecycle recipient. The `text_message` body includes `conversation_id`, `sender_phone_number`, and outbound `recipients[]`; group lifecycle events also set top-level `data.recipient_phone_number` so receivers know which recipient changed state. Fire-and-forget — response status is logged but does not affect text processing.
**Python**
```python
# Route incoming calls to a webhook (synchronous, response drives call)
inkbox.phone_numbers.update(
number.id,
incoming_call_action="webhook",
incoming_call_webhook_url="https://example.com/calls",
)
# Subscribe a URL to text lifecycle events (fan-out fire-and-forget)
inkbox.webhooks.subscriptions.create(
phone_number_id=number.id,
url="https://example.com/texts",
event_types=["text.received", "text.sent", "text.delivered"],
)
```
**TypeScript**
```typescript
// Route incoming calls to a webhook (synchronous, response drives call)
await inkbox.phoneNumbers.update(number.id, {
incomingCallAction: "webhook",
incomingCallWebhookUrl: "https://example.com/calls",
});
// Subscribe a URL to text lifecycle events (fan-out fire-and-forget)
await inkbox.webhooks.subscriptions.create({
phoneNumberId: number.id,
url: "https://example.com/texts",
eventTypes: ["text.received", "text.sent", "text.delivered"],
});
```
## Subscribing to text events
Same pattern with `phoneNumberId`. The text catalog is `text.received`, `text.sent`, `text.delivered`, `text.delivery_failed`, `text.delivery_unconfirmed`.
**Python**
```python
inkbox.webhooks.subscriptions.create(
phone_number_id=number.id,
url="https://example.com/texts",
event_types=[
"text.received",
"text.sent",
"text.delivered",
"text.delivery_failed",
"text.delivery_unconfirmed",
],
)
```
**TypeScript**
```typescript
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",
],
});
```
- **Mail events** carry `data.contacts` — a **list** of `{bucket, address, id, name}` entries, one per matched recipient. The list is always present and sparse: unmatched recipients are absent, and `"contacts": []` means nothing matched. Inbound mail resolves `from_address` plus every CC; outbound mail resolves every To, CC, and BCC. Pair entries back to recipients on `(bucket, address)` — the same address can appear in multiple buckets and will produce one entry per bucket. See [Mail webhooks → Peer resolution](/docs/api/mail/webhooks) for the full pairing rules, the intra-bucket dedupe behavior, and the per-event cap.
- **Text events** carry `data.contacts` and `data.agent_identities` — **lists** of `{id, name, memories}` (contacts, where `memories` is `string[]`) or `{id, agent_handle, display_name}` (identities) entries. Lists are always present and possibly empty. Inbound and 1:1 events match the sender / counterparty; outbound group lifecycle events match per-recipient context, and `data.recipient_phone_number` plus `data.text_message.recipients[]` carry the per-leg state.
- **Inbound calls** carry top-level `contacts` and `agent_identities` — same plural-list shape. Match key is `remote_phone_number`.
**Python**
```python
# List the subscriptions on a mailbox
subs = inkbox.webhooks.subscriptions.list(mailbox_id=mailbox.id)
# Repoint a subscription, or narrow its event set
inkbox.webhooks.subscriptions.update(
subs[0].id,
url="https://new-host.example/hook",
event_types=["message.received"],
)
# Remove a subscription — delivery stops immediately
inkbox.webhooks.subscriptions.delete(subs[0].id)
```
**TypeScript**
```typescript
// List the subscriptions on a mailbox
const subs = await inkbox.webhooks.subscriptions.list({ mailboxId: mailbox.id });
// Repoint a subscription, or narrow its event set
await inkbox.webhooks.subscriptions.update(subs[0].id, {
url: "https://new-host.example/hook",
eventTypes: ["message.received"],
});
// Remove a subscription — delivery stops immediately
await inkbox.webhooks.subscriptions.delete(subs[0].id);
```
See [Webhook Subscriptions](/docs/api/webhooks/subscriptions) for the full request and response shapes, validation rules, and error codes.
## Inspecting deliveries and replaying misses
Every delivery attempt is logged. Inspect what was sent — and replay a delivery your endpoint missed — through `inkbox.webhooks.deliveries`. Replay reuses the original event's `event_id`, so an endpoint that already processed the event dedupes the replay away; it only helps a receiver that never got the event.
**Python**
```python
# Find recent failed deliveries for a subscription
failed = inkbox.webhooks.deliveries.list(
subscription_id=subs[0].id,
success=False,
)
# Re-send a missed delivery to the subscription's current URL
if failed:
inkbox.webhooks.deliveries.replay(failed[0].id)
```
**TypeScript**
```typescript
// Find recent failed deliveries for a subscription
const failed = await inkbox.webhooks.deliveries.list({
subscriptionId: subs[0].id,
success: false,
});
// Re-send a missed delivery to the subscription's current URL
if (failed.length > 0) {
await inkbox.webhooks.deliveries.replay(failed[0].id);
}
```
See [Webhook Deliveries](/docs/api/webhooks/deliveries) for the delivery object, query filters, and replay error codes.
## Subscribing to iMessage events
iMessage subscriptions are owned by the **agent identity** whether its conversations use the shared service or an attached dedicated number. The catalog is `imessage.received` and `imessage.reaction_received` for inbound traffic, plus `imessage.sent`, `imessage.delivered`, and `imessage.delivery_failed` for outbound delivery status.
**Python**
```python
inkbox.webhooks.subscriptions.create(
agent_identity_id=identity.id,
url="https://example.com/imessage",
event_types=["imessage.received", "imessage.reaction_received"],
)
```
**TypeScript**
```typescript
await inkbox.webhooks.subscriptions.create({
agentIdentityId: identity.id,
url: "https://example.com/imessage",
eventTypes: ["imessage.received", "imessage.reaction_received"],
});
```
Standard envelope; `data.message` is populated on `imessage.received` and the delivery-lifecycle events, and `data.reaction` on `imessage.reaction_received`. Fan-out pauses while the identity is paused or not iMessage-enabled, and contact-rule-blocked traffic never emits events. See [iMessage webhooks](/docs/api/imessage/webhooks) for payload shapes. Received events (`message.received`, `text.received`, `imessage.received`) can also carry an additive `data.context` block when the subscription configures [conversation context](/docs/api/webhooks/subscriptions#conversation-context-context_config).
## Subscribing to call-lifecycle events
Call-lifecycle subscriptions are owned by the **agent identity**, like iMessage — a call may ride the identity's shared iMessage line rather than a number you own, and the identity is the stable owner either way. The catalog is `call.ended`, delivered once after a connected call terminates ([Inkbox Voice AI](/docs/capabilities/phone/hosted-call-agent) calls, inbound and placed alike, also report when they never connect — see [when it fires](/docs/api/phone/webhooks#call-ended-webhook)).
Unlike `phone.incoming_call` — the synchronous, per-number control-plane callback whose response body decides whether Inkbox answers — `call.ended` is a **fire-and-forget, replayable** fan-out. You receive it even if you never held the live [media WebSocket](/docs/api/phone/media-stream), the response status is logged but never affects call processing, and the stable event `id` (`evt_...`) is your idempotency key across the original delivery and any [replays](#inspecting-deliveries-and-replaying-misses).
**Python**
```python
inkbox.webhooks.subscriptions.create(
agent_identity_id=identity.id,
url="https://example.com/calls-ended",
event_types=["call.ended"],
)
```
**TypeScript**
```typescript
await inkbox.webhooks.subscriptions.create({
agentIdentityId: identity.id,
url: "https://example.com/calls-ended",
eventTypes: ["call.ended"],
});
```
Standard signed envelope. `data.call` is the terminated call (webhook wire shape, so no `is_blocked`) with a derived `duration_seconds`; `data.contacts` and `data.agent_identities` resolve the remote party. Two transcript fields ride the payload:
- **`data.transcript`** — an inline, **abridged** (middle-cut) transcript block, present when the call has transcribed turns at dispatch time; it is `null` otherwise. Its `entries` mix `{party, text, ts_ms}` turns with an `{marker: "abridged", omitted_turns, omitted_ms}` marker where turns were dropped to fit the size budget, and `abridged` is `true` when anything was omitted. A signing-secret-only consumer can act on this copy without an API key.
- **`data.transcript_url`** — **always present**, and points at [`GET /phone/calls/{id}/transcripts`](/docs/api/phone/transcripts), the authoritative verbatim record. Fetching it requires an API key (see [Transcripts](/docs/api/phone/transcripts) for scoping). The inline block reads transcripts as of dispatch time and may lag the very last turn, so `transcript_url` is the source of truth when you need the complete transcript.
This subscription is an independent row from a phone number's `incoming_call_webhook_url` / `auto_accept` inbound routing — the two never interfere. For [Inkbox Voice AI](/docs/capabilities/phone/hosted-call-agent) calls the payload additionally carries an `outcome` and the `post_call_action_items` the agent recorded — the event is the one atomic delivery of the whole post-call package. See [Phone webhooks](/docs/api/phone/webhooks#call-ended-webhook) for the full payload shape.
## Incoming-call webhooks (still per-number)
`phone.incoming_call` is the only event that lives on the phone-number resource, because the receiver's response body controls whether Inkbox answers, rejects, or ignores the call. Fan-out makes no sense here.
**Python**
```python
inkbox.phone_numbers.update(
number.id,
incoming_call_action="webhook",
incoming_call_webhook_url="https://example.com/calls",
)
```
**TypeScript**
```typescript
await inkbox.phoneNumbers.update(number.id, {
incomingCallAction: "webhook",
incomingCallWebhookUrl: "https://example.com/calls",
});
```
## Peer resolution
Every webhook payload carries two parallel lookups for the remote parties on the event:
- `contacts` — address-book matches for the remote parties. Contacts are organization-wide, so every agent sees the same matches.
- `agent_identities` — active internal-agent matches in the same organization.
Both lists are **always present and possibly empty**, never `null`. A single peer can land in both — receivers decide precedence per row. The shape differs by surface:
- **Mail events** carry `data.contacts` and `data.agent_identities`, each a list of `{bucket, address, id, ...}` entries — one per matched recipient. Pair entries back to their recipient slot on `(bucket, address)` since the same address can appear in multiple buckets. See [Mail webhooks → Peer resolution](/docs/api/mail/webhooks) for the full pairing rules.
- **Text events** carry `data.contacts` and `data.agent_identities` keyed off the remote party. Each entry is `{id, name, memories}` (contacts, where `memories` is `string[]`) or `{id, agent_handle, display_name?}` (identities).
- **Inbound calls** carry top-level `contacts` and `agent_identities` with the same per-entry shape as text events.
- **iMessage events** carry `data.contacts` and `data.agent_identities` keyed off the 1:1 counterparty or the sender of an inbound group message, with the same per-entry shape as text events. Group delivery events have no single counterparty, so both lists may be empty.
- **Call-ended events** carry `data.contacts` and `data.agent_identities` keyed off `data.call.remote_phone_number`, with the same per-entry shape as text events.
Contact matches come from the organization-wide contact directory and are the same for every identity. Agent-identity matches include active identities in the same organization when the email or phone matches.
## Signing keys
Signing keys are **per agent identity**: each identity has its own key that verifies the webhooks (and WebSocket upgrades) for that identity's mailbox, phone number, iMessage, and call-lifecycle traffic. The first webhook subscription you create for a keyless identity returns its signing secret once, in the create response. See the [Signing Keys](/docs/signing-keys) page for details on creating and rotating keys.
## Verifying webhook signatures
Use `verify_webhook` / `verifyWebhook` to confirm that an incoming request was sent by Inkbox. Pass the plaintext key from your [signing key](/docs/signing-keys) as the `secret`.
**Python**
```python
from inkbox import verify_webhook
# FastAPI
@app.post("/hooks/mail")
async def mail_hook(request: Request):
raw_body = await request.body()
if not verify_webhook(
payload=raw_body,
headers=request.headers,
secret="whsec_...",
):
raise HTTPException(status_code=403)
...
# Flask
@app.post("/hooks/mail")
def mail_hook():
raw_body = request.get_data()
if not verify_webhook(
payload=raw_body,
headers=request.headers,
secret="whsec_...",
):
abort(403)
...
```
**TypeScript**
```typescript
import { verifyWebhook } from "@inkbox/sdk";
// Express — use express.raw() to get the raw body Buffer
app.post("/hooks/mail", express.raw({ type: "*/*" }), (req, res) => {
const valid = verifyWebhook({
payload: req.body,
headers: req.headers,
secret: "whsec_...",
});
if (!valid) return res.status(403).end();
// handle event ...
});
```
## Receiving webhooks (typed)
The SDK exports typed payload shapes for every webhook body. Pair `verify_webhook` / `verifyWebhook` with a single `cast(...)` or `as ...` and discriminate on `event_type`.
### Mail handler
Mail events carry `data.contacts` and `data.agent_identities` as lists. Pair each entry to its recipient field on `(bucket, address)` — the same address can match in multiple buckets and will appear once per bucket per list.
**Python**
```python
import json
from typing import cast
from inkbox import MailWebhookPayload, verify_webhook
@app.post("/hooks/mail")
async def mail_hook(request: Request):
raw_body = await request.body()
if not verify_webhook(
payload=raw_body,
headers=request.headers,
secret="whsec_...",
):
raise HTTPException(status_code=403)
payload = cast(MailWebhookPayload, json.loads(raw_body))
msg = payload["data"]["message"]
contacts = payload["data"]["contacts"] # always present
agent_identities = payload["data"]["agent_identities"] # always present
# Inbound: look up the sender (and any internal-agent CC peers)
if payload["event_type"] == "message.received":
sender = next(
(c for c in contacts if c["bucket"] == "from"),
None,
)
if sender is not None:
logger.info("inbound from known contact %s", sender["id"])
for ai in agent_identities:
logger.info(
"internal-agent peer %s @%s (bucket=%s)",
ai["id"], ai["agent_handle"], ai["bucket"],
)
# Outbound: bcc_addresses is populated; surface every matched recipient
if msg["direction"] == "outbound":
for c in contacts:
logger.info(
"matched %s recipient %s -> contact %s",
c["bucket"], c["address"], c["id"],
)
```
**TypeScript**
```typescript
import { MailWebhookPayload, verifyWebhook } from "@inkbox/sdk";
app.post("/hooks/mail", express.raw({ type: "*/*" }), (req, res) => {
if (
!verifyWebhook({
payload: req.body,
headers: req.headers,
secret: "whsec_...",
})
) {
return res.status(403).end();
}
const payload = JSON.parse(req.body.toString()) as MailWebhookPayload;
const msg = payload.data.message;
const contacts = payload.data.contacts; // always present
const agentIdentities = payload.data.agent_identities; // always present
if (payload.event_type === "message.received") {
const sender = contacts.find((c) => c.bucket === "from");
if (sender) {
console.log("inbound from known contact", sender.id);
}
for (const ai of agentIdentities) {
console.log(
`internal-agent peer ${ai.id} @${ai.agent_handle} (bucket=${ai.bucket})`,
);
}
}
if (msg.direction === "outbound") {
for (const c of contacts) {
console.log(
`matched ${c.bucket} recipient ${c.address} -> contact ${c.id}`,
);
}
}
res.status(204).end();
});
```
### Text handler
Text events carry `data.contacts` and `data.agent_identities` as lists (always present, possibly empty). In group lifecycle events, `data.recipient_phone_number` names the recipient this webhook is about, while `data.text_message.recipients[]` carries every recipient's current delivery state.
**Python**
```python
import json
from typing import cast
from inkbox import TextWebhookPayload, verify_webhook
@app.post("/hooks/text")
async def text_hook(request: Request):
raw_body = await request.body()
if not verify_webhook(
payload=raw_body,
headers=request.headers,
secret="whsec_...",
):
raise HTTPException(status_code=403)
payload = cast(TextWebhookPayload, json.loads(raw_body))
data = payload["data"]
msg = data["text_message"]
# Group outbound: per-recipient lifecycle. data.recipient_phone_number
# identifies which leg this event is for; per-leg state lives in
# text_message.recipients[].
is_group_outbound = (
msg["direction"] == "outbound" and msg["remote_phone_number"] is None
)
if is_group_outbound:
leg = data["recipient_phone_number"]
for r in msg["recipients"] or []:
if r["recipient_phone_number"] == leg:
logger.info(
"leg %s status=%s error=%s",
leg, r["delivery_status"], r["error_code"],
)
match payload["event_type"]:
case "text.delivery_failed":
msg = payload["data"]["text_message"]
recipient = (
payload["data"]["recipient_phone_number"]
or msg["remote_phone_number"]
)
receipt = next(
(
r for r in (msg["recipients"] or [])
if r["recipient_phone_number"] == recipient
),
None,
)
logger.error(
"text to %s failed: %s (%s)",
recipient,
(receipt or msg)["error_code"],
(receipt or msg)["error_detail"],
)
case "text.delivered":
# For group events, check payload["data"]["recipient_phone_number"].
...
case "text.received":
for c in data["contacts"]:
logger.info("inbound from known contact %s", c["id"])
for ai in data["agent_identities"]:
logger.info("inbound from internal agent @%s", ai["agent_handle"])
```
**TypeScript**
```typescript
import { TextWebhookPayload, verifyWebhook } from "@inkbox/sdk";
app.post("/hooks/text", express.raw({ type: "*/*" }), (req, res) => {
if (
!verifyWebhook({
payload: req.body,
headers: req.headers,
secret: "whsec_...",
})
) {
return res.status(403).end();
}
const payload = JSON.parse(req.body.toString()) as TextWebhookPayload;
switch (payload.event_type) {
case "text.delivery_failed": {
const m = payload.data.text_message;
const recipient =
payload.data.recipient_phone_number ?? m.remote_phone_number;
const receipt = m.recipients?.find(
(r) => r.recipient_phone_number === recipient,
);
console.error(
`text to ${recipient} failed`,
receipt?.error_code ?? m.error_code,
receipt?.error_detail ?? m.error_detail,
);
break;
}
case "text.delivered":
// For group events, check payload.data.recipient_phone_number.
break;
case "text.received":
for (const c of payload.data.contacts) {
console.log("inbound from known contact", c.id);
}
for (const ai of payload.data.agent_identities) {
console.log("inbound from internal agent @" + ai.agent_handle);
}
}
switch (payload.event_type) {
case "text.delivery_failed":
console.error(
`SMS to ${msg.remote_phone_number} failed`,
msg.error_code,
msg.error_detail,
);
break;
case "text.received":
for (const c of data.contacts) {
console.log("inbound from known contact", c.id);
}
for (const ai of data.agent_identities) {
console.log("inbound from internal agent @" + ai.agent_handle);
}
break;
}
res.status(204).end();
});
```
### Call handler
Inbound-call events carry top-level `contacts` and `agent_identities` (the call payload is flat — no envelope).
**Python**
```python
import json
from typing import cast
from inkbox import PhoneIncomingCallWebhookPayload, verify_webhook
@app.post("/hooks/calls")
async def call_hook(request: Request):
raw_body = await request.body()
if not verify_webhook(
payload=raw_body,
headers=request.headers,
secret="whsec_...",
):
raise HTTPException(status_code=403)
payload = cast(PhoneIncomingCallWebhookPayload, json.loads(raw_body))
for c in payload["contacts"]:
logger.info("inbound call from known contact %s", c["id"])
for ai in payload["agent_identities"]:
logger.info("inbound call from internal agent @%s", ai["agent_handle"])
# Respond with how Inkbox should handle the call
return {"action": "answer", "client_websocket_url": "wss://..."}
```
**TypeScript**
```typescript
import { PhoneIncomingCallWebhookPayload, verifyWebhook } from "@inkbox/sdk";
app.post("/hooks/calls", express.raw({ type: "*/*" }), (req, res) => {
if (
!verifyWebhook({
payload: req.body,
headers: req.headers,
secret: "whsec_...",
})
) {
return res.status(403).end();
}
const payload = JSON.parse(req.body.toString()) as PhoneIncomingCallWebhookPayload;
for (const c of payload.contacts) {
console.log("inbound call from known contact", c.id);
}
for (const ai of payload.agent_identities) {
console.log("inbound call from internal agent @" + ai.agent_handle);
}
// Respond with how Inkbox should handle the call
res.json({ action: "answer", client_websocket_url: "wss://..." });
});
```
### Call-lifecycle handler
`call.ended` carries a standard envelope. Discriminate on `event_type`, then act on the inline `data.transcript` when it's present, or fetch `data.transcript_url` for the verbatim record. The response body is ignored.
**Python**
```python
import json
from typing import cast
from inkbox import CallEndedWebhookPayload, verify_webhook
@app.post("/hooks/calls-ended")
async def call_ended_hook(request: Request):
raw_body = await request.body()
if not verify_webhook(
payload=raw_body,
headers=request.headers,
secret="whsec_...",
):
raise HTTPException(status_code=403)
payload = cast(CallEndedWebhookPayload, json.loads(raw_body))
if payload["event_type"] != "call.ended":
return
data = payload["data"]
call = data["call"]
logger.info(
"call %s ended: %s, %ss",
call["id"], call["status"], call["duration_seconds"],
)
# Inline transcript is present when the call has transcribed turns.
transcript = data["transcript"]
if transcript is not None:
for entry in transcript["entries"]:
if entry.get("marker") == "abridged":
continue # skip the middle-cut marker
logger.info("[%s] %s", entry["party"], entry["text"])
# transcript_url is always present and authoritative (needs an API key).
logger.info("full transcript at %s", data["transcript_url"])
```
**TypeScript**
```typescript
import { CallEndedWebhookPayload, verifyWebhook } from "@inkbox/sdk";
app.post("/hooks/calls-ended", express.raw({ type: "*/*" }), (req, res) => {
if (
!verifyWebhook({
payload: req.body,
headers: req.headers,
secret: "whsec_...",
})
) {
return res.status(403).end();
}
const payload = JSON.parse(req.body.toString()) as CallEndedWebhookPayload;
if (payload.event_type !== "call.ended") return res.status(204).end();
const { call, transcript, transcript_url } = payload.data;
console.log(`call ${call.id} ended: ${call.status}, ${call.duration_seconds}s`);
// Inline transcript is present when the call has transcribed turns.
if (transcript) {
for (const entry of transcript.entries) {
if (entry.marker === "abridged") continue; // skip the middle-cut marker
console.log(`[${entry.party}] ${entry.text}`);
}
}
// transcript_url is always present and authoritative (needs an API key).
console.log("full transcript at", transcript_url);
res.status(204).end();
});
```
Wire shapes are intentionally **snake_case** — they mirror the raw JSON body, not the SDK's parsed (camelCase in TypeScript) response types — so `JSON.parse(body) as MailWebhookPayload` and `cast(MailWebhookPayload, json.loads(body))` round-trip without a transformer. Enum-valued fields like `direction`, `status`, and `delivery_status` are string-literal unions rather than the SDK's `StrEnum` / TS `enum` exports, because `json.loads` / `JSON.parse` produce bare strings and string-literal unions narrow cleanly under mypy / pyright / tsc.
The mail-side per-recipient entry is exposed as `WebhookMailContact` (`{ bucket, address, id, name }`) and the bucket enum as `MailContactBucket` (`"from" | "to" | "cc" | "bcc"`), available alongside `MailWebhookPayload`. Text and inbound-call events expose plural lists: `WebhookContact[]` (`{ id, name, memories }`, where `memories` is `string[]`) and `WebhookAgentIdentity[]` (`{ id, agent_handle, display_name }`). Text message payloads additionally expose outbound `recipients[]` entries and top-level `recipient_phone_number` for group lifecycle fan-out. The `call.ended` payload is exposed as `CallEndedWebhookPayload`, with `WebhookPhoneCall` for `data.call`, `WebhookCallTranscript` for the inline `data.transcript` block, and `WebhookPostCallActionItem` (Python: `WebhookPostCallActionItemWire`) for `data.post_call_action_items` entries; the Voice AI additions (`mode`, `reason`, `outcome`, `post_call_action_items`) are optional-with-defaults so payloads from before SDK 0.4.22 parse unchanged.
---
---
# Signing keys
description: Create and rotate per-identity webhook signing keys for verifying Inkbox webhook payloads
---
# Signing keys
Each agent identity has its own signing key, used to verify the webhooks and WebSocket upgrades for that identity's mailbox, phone number, and iMessage traffic. Verify incoming payloads for an identity's [mail](/docs/api/mail/webhooks) and [phone](/docs/api/phone/webhooks) channels with that identity's key.
Until an identity has a key, its webhooks and WebSocket connections are sent unsigned. Once a key exists, all of that identity's webhooks and WebSocket upgrades are signed automatically.
The plaintext key is returned **only once** when created or rotated. Store it securely — it cannot be retrieved again.
Your existing webhooks continue to verify with the same secret.
---
## How an identity first gets its key
A brand-new agent identity has no signing key. There are two ways one comes into existence:
- **Automatically, on the first subscription.** The first webhook subscription you create for a keyless identity returns that identity's signing secret once, in the `signing_key` field of the create response. Store it then — it isn't shown again. See [Subscriptions](/docs/api/webhooks/subscriptions).
- **Explicitly, via the POST route below.** Call `POST /identities/{agent_handle}/signing-key` to mint a key up front (or to rotate an existing one).
---
## Get signing key status `GET`
```
GET /identities/{agent_handle}/signing-key
```
Report whether this agent identity has a signing key configured. Does not return the secret.
### Response (200)
```json
{
"configured": true,
"created_at": "2026-03-13T15:30:45Z"
}
```
| Field | Type | Description |
| :--- | :--- | :--- |
| `configured` | boolean | `true` if this identity has a signing key. |
| `created_at` | string \| null | Timestamp the key was created or last rotated (ISO 8601); `null` when not configured. |
### Code examples
**cURL**
```bash
curl -X GET "https://inkbox.ai/api/v1/identities/my-agent/signing-key" \
-H "X-API-Key: YOUR_API_KEY"
```
**JavaScript**
```javascript
const response = await fetch(
"https://inkbox.ai/api/v1/identities/my-agent/signing-key",
{ headers: { "X-API-Key": "YOUR_API_KEY" } }
);
const { configured, created_at } = await response.json();
```
**Python**
```python
import requests
response = requests.get(
"https://inkbox.ai/api/v1/identities/my-agent/signing-key",
headers={"X-API-Key": "YOUR_API_KEY"},
)
data = response.json()
```
---
## Create or rotate signing key `POST`
```
POST /identities/{agent_handle}/signing-key
```
Create a signing key for this agent identity, or rotate the existing one. On the first call a new key is generated. On subsequent calls the old key is replaced with a fresh one.
### Response (201)
```json
{
"signing_key": "7K9x2mP4qR8vT1wY3zA5bC6dE0fG...",
"created_at": "2026-03-13T15:30:45Z"
}
```
| Field | Type | Description |
| :--- | :--- | :--- |
| `signing_key` | string | Plaintext signing key. Store this securely — it is only returned once. |
| `created_at` | string | Timestamp of creation or rotation (ISO 8601). |
### Error responses
| Status | Description |
| :--- | :--- |
| `503` | Webhook signing is not configured on the server. |
### Code examples
**cURL**
```bash
curl -X POST "https://inkbox.ai/api/v1/identities/my-agent/signing-key" \
-H "X-API-Key: YOUR_API_KEY"
```
**JavaScript**
```javascript
const response = await fetch(
"https://inkbox.ai/api/v1/identities/my-agent/signing-key",
{
method: "POST",
headers: { "X-API-Key": "YOUR_API_KEY" },
}
);
const { signing_key, created_at } = await response.json();
// Store signing_key securely — it cannot be retrieved again
```
**Python**
```python
import requests
response = requests.post(
"https://inkbox.ai/api/v1/identities/my-agent/signing-key",
headers={"X-API-Key": "YOUR_API_KEY"},
)
data = response.json()
# Store data["signing_key"] securely — it cannot be retrieved again
```
---
## Deprecated: org-level signing key
The org-level endpoints `POST /signing-keys` and `GET /signing-keys` are **deprecated**. Their responses carry a `Link` header pointing at the per-identity route above. Move to `…/identities/{agent_handle}/signing-key`.
While they remain available, behavior depends on the caller's auth scope:
| Endpoint | Agent-scoped [API key](/docs/api-keys) | [Admin API key](/docs/api-keys), or manage from the [Inkbox Console](https://inkbox.ai/console) |
| :--- | :--- | :--- |
| `POST /signing-keys` | Rotates that one identity's key (returns the secret once). | Returns `409` — rotate a specific identity via `POST /identities/{agent_handle}/signing-key`. |
| `GET /signing-keys` | Reports that identity's status. | Reports an org-aggregate status: `configured` is `true` if any identity in the org has a key. |
---
## Verifying webhook signatures
Once you have an identity's signing key, use it to verify incoming webhook payloads for that identity. See:
- [Mail webhook verification](/docs/api/mail/webhooks#verifying-webhook-signatures)
- [Phone webhook verification](/docs/api/phone/webhooks#verifying-webhook-signatures)
---
---
# Skills
description: Load Inkbox skills into your AI agent so it automatically knows how to use the SDK
---
# Skills
Inkbox skills are reference files that teach your AI agent how to use the Inkbox SDK. Once installed, your agent automatically knows how to work with email, phone, authenticator, and vault features.
> **Using an AI coding assistant?**
> Install the Inkbox skill to give it instant knowledge of the SDK — works with Claude Code, Cursor, and any `skills`-compatible agent.
```bash
npx skills add https://inkbox.ai
```
## Manual install (Claude Code)
```bash
# clone the repo
git clone https://github.com/inkbox-ai/inkbox
# copy the skill you need
cp -r inkbox/skills/inkbox-ts ~/.claude/skills/
cp -r inkbox/skills/inkbox-python ~/.claude/skills/
```
## Prerequisites
1. **Install the SDK** in your project:
```bash
# Python
pip install inkbox
# TypeScript / Node
npm install @inkbox/sdk
```
2. **Get an API key** from the [Inkbox Console](https://inkbox.ai/console/)
## Available skills
| Skill | Language | Description |
|---|---|---|
| [TypeScript SDK](/docs/skills/typescript) | TypeScript / Node ≥ 22 | `@inkbox/sdk` surface — identities, email, phone, text/SMS, contacts, notes, contact rules, vault, TOTP, webhooks, whoami |
| [Python SDK](/docs/skills/python) | Python ≥ 3.11 | `inkbox` surface — identities, email, phone, text/SMS, contacts, notes, contact rules, vault, TOTP, webhooks, whoami |
| [Tunnels](/docs/skills/tunnels) | Python ≥ 3.11 / TypeScript / Node ≥ 22 | Bring a local server online at `my-agent.inkboxwire.com` — lifecycle via identity surface, edge vs passthrough TLS, URL forwarding, in-process Fetch / ASGI / WebSocket handlers |
| [Agent self-signup](/docs/skills/agent-self-signup) | SDK-agnostic | End-to-end self-signup flow — register, verify, resend, status — with Python, TypeScript, and curl examples |
| [CLI](/docs/skills/cli) | Shell | `@inkbox/cli` surface — shell-native workflows |
[View the source →](https://github.com/inkbox-ai/inkbox/tree/main/skills)
---
---
# TypeScript SDK
description: Give your coding agent full knowledge of the Inkbox TypeScript SDK
---
# TypeScript SDK Skill
This skill teaches your coding agent how to use the `@inkbox/sdk` TypeScript package. Once installed, the agent automatically knows the correct imports, initialization patterns, and method signatures for the entire SDK surface — identities, email, phone, SMS/MMS, vault, contacts, notes, contact rules, TOTP, tunnels, whoami, and webhooks.
[View the source →](https://github.com/inkbox-ai/inkbox/tree/main/skills/inkbox-ts)
## What your agent learns
| Feature | Operations |
|---|---|
| Identities | Create, get, list, update (rename, metadata, contact-rule filter modes `mailFilterMode` / `phoneFilterMode` / `imessageFilterMode`), refresh, delete |
| Channel management | Provision phone numbers, assign and unlink mailboxes and phone numbers |
| Agent self-signup | Static `Inkbox.signup` / `verifySignup` / `resendSignupVerification` / `getSignupStatus` — claim flow and restrictions |
| Email — send | Text, HTML, CC/BCC, base64 attachments, threaded replies via `inReplyToMessageId` |
| Email — read | Paginated `iterEmails` / `iterUnreadEmails`, filter by direction, fetch full threads oldest-first, mark read |
| Email — threads | Thread folders (`inbox` / `spam` / `archive` / `blocked`), per-thread folder updates, folder listing |
| Mailbox imports | `inkbox.mailboxes.imports` create / direct upload / start / get / list / wait / cancel for MBOX, EML, and ZIP archives; `refreshUploadTarget`, five-second default polling with `onPoll`, terminal job results, non-cancelling timeouts, counters, and `Message.importJobId` provenance |
| Phone calls | Place outbound calls with client WebSocket audio, list history, transcript segments per party |
| Text messages (SMS/MMS) | Send 1:1 SMS/MMS and beta group MMS (`identity.sendText({ to? \| conversationId?, text?, mediaUrls? })`), list and filter, single message with MMS media, conversation summaries with `includeGroups` and `latestHasMedia`, per-conversation messages by UUID or 1:1 remote key, mark read, admin search / update / delete. Some carriers may reject group chats or MMS from 10DLC numbers |
| iMessage | Send 1:1 messages or dedicated-outbound groups with `identity.sendIMessage({ to: [...] })`, reply or retry failed creation by `conversationId`, and opt groups into message/conversation lists with `includeGroups: true`. `IMessageGroupCreationStatus` and `groupCreationStatus` expose `creating`, `not_created`, or `ready`; failed attempts stay in the same conversation. `sendStyle` works on group creation and replies and can accompany media. Existing message-ID reaction methods support inbound group messages, with nullable reaction `assignmentId`. Group read receipts and typing are not supported |
| Vault — secrets | Initialize with client-side encryption, unlock, CRUD all payload types (`login`, `api_key`, `key_pair`, `ssh_key`, `other`), metadata-only listing without unlock |
| Vault — identity-scoped | `identity.getCredentials()` with typed per-type accessors (`getLogin`, `getApiKey`, `getSshKey`, `getKeyPair`), filtered by access rules |
| TOTP | Store inside `LoginPayload.totp`, parse `otpauth://` URIs, generate codes client-side, set / remove on existing logins |
| Mailboxes (admin) | List, get, update display name, search, delete. The per-mailbox `filterMode` update is **deprecated** — set `mailFilterMode` on the identity instead |
| Phone numbers (admin) | Provision local numbers (with optional state), update incoming-call action (`webhook` / `auto_accept` / `auto_reject`), transcript search, release |
| Contact rules | Identity-keyed mail (exact email / domain) and phone (exact E.164) allow/block rules, addressed by agent handle (`mailIdentityContactRules` / `phoneIdentityContactRules`, plus `identity.createMailContactRule(...)` etc.); whitelist vs blacklist via the identity's `mailFilterMode` / `phoneFilterMode`; action updates and duplicate-rule handling. The per-mailbox / per-number rule resources are **deprecated** |
| Contacts | CRUD with emails / phones / addresses, reverse-lookup variants, per-identity or wildcard access grants, bulk vCard import (≤5 MiB, ≤1000 cards), vCard 4.0 export |
| Notes | CRUD free-form notes, list and filter, per-identity access grants (no wildcard) |
| Whoami | Inspect caller's auth type (API key vs [Inkbox Console](https://inkbox.ai/console) user session), organization ID, API-key subtype constants for [admin vs agent-scoped](/docs/api-keys) branching |
| Webhooks | Per-identity signing keys — create / rotate / status by agent handle (`signingKeys.createOrRotate` / `getStatus`, plus `identity.createSigningKey()` / `identity.getSigningKeyStatus()`); the org-level signing key is **deprecated**. The first webhook subscription created for a keyless identity returns its signing secret **once**. Verify incoming requests via HMAC-SHA256 over `{requestId}.{timestamp}.{body}`; typed receiver-side payloads (`MailWebhookPayload`, `TextWebhookPayload`, `PhoneIncomingCallWebhookPayload`) with `event_type` discrimination across all six mail events and all five `text.*` lifecycle events; mail events carry `data.contacts: WebhookMailContact[]` (`{bucket, address, id, name}`, sparse, with `MailContactBucket = "from" \| "to" \| "cc" \| "bcc"`) plus `bcc_addresses` on outbound; text events carry `conversation_id`, `sender_phone_number`, outbound `recipients[]`, and top-level `recipient_phone_number` for group lifecycle events, plus plural `data.contacts: WebhookContact[]` (`{id, name, memories: string[]}`) and `data.agent_identities: WebhookAgentIdentity[]` (always present, possibly empty); inbound-call payloads expose the same plural pair at top level. Contact matches are organization-wide; agent-identity matches retain identity visibility filtering |
| Error handling | `InkboxAPIError` with status code and structured detail; narrower `DuplicateContactRuleError` subclass |
| Tunnels | `connect(inkbox, { tunnelId, forwardTo \| handler, wsHandler? })` from `@inkbox/sdk/tunnels/connect` for a public `my-agent.inkboxwire.com` URL; URL forwarding or Fetch-API + WebSocket handlers; edge vs passthrough TLS. Read-only API on tunnels (`list` / `get` / `update`) plus `connect()` and `signCsr()` for passthrough; tunnel creation and deletion happen through the identity surface. Node ≥ 22, POSIX-only data plane. |
## Install
```bash
# via skills CLI (recommended)
npx skills add inkbox-ai/inkbox/skills
# manual (Claude Code)
git clone https://github.com/inkbox-ai/inkbox
cp -r inkbox/skills/inkbox-ts ~/.claude/skills/
```
## Prerequisites
- Node.js ≥ 22 (declared in `@inkbox/sdk` `engines.node`)
- `@inkbox/sdk` installed in your project (`npm install @inkbox/sdk`)
- An [Inkbox](https://www.inkbox.ai) API key from the [Console](https://inkbox.ai/console/)
---
---
# Python SDK
description: Give your coding agent full knowledge of the Inkbox Python SDK
---
# Python SDK Skill
This skill teaches your coding agent how to use the `inkbox` Python package. Once installed, the agent automatically knows the correct imports, initialization patterns, and method signatures for the entire SDK surface — identities, email, phone, SMS/MMS, vault, contacts, notes, contact rules, TOTP, tunnels, whoami, and webhooks.
[View the source →](https://github.com/inkbox-ai/inkbox/tree/main/skills/inkbox-python)
## What your agent learns
| Feature | Operations |
|---|---|
| Identities | Create, get, list, update (rename, metadata, contact-rule filter modes `mail_filter_mode` / `phone_filter_mode` / `imessage_filter_mode`), refresh, delete |
| Channel management | Provision phone numbers, assign and unlink mailboxes and phone numbers |
| Agent self-signup | Class methods `Inkbox.signup` / `verify_signup` / `resend_signup_verification` / `get_signup_status` — claim flow and restrictions |
| Email — send | Text, HTML, CC/BCC, base64 attachments, threaded replies via `in_reply_to_message_id` |
| Email — read | Paginated `iter_emails` / `iter_unread_emails`, filter by direction, fetch full threads oldest-first, mark read |
| Email — threads | Thread folders (`inbox` / `spam` / `archive` / `blocked`), per-thread folder updates, folder listing |
| Mailbox imports | `inkbox.mailboxes.imports` create / direct upload / start / get / list / wait / cancel for MBOX, EML, and ZIP archives; upload-target refresh, five-second default polling, terminal job results, non-cancelling timeouts, counters, and `Message.import_job_id` provenance |
| Phone calls | Place outbound calls with client WebSocket audio, list history, transcript segments per party |
| Text messages (SMS/MMS) | Send 1:1 SMS/MMS and beta group MMS (`identity.send_text(to=... \| conversation_id=..., text=None, media_urls=None)`), list and filter, single message with MMS media, conversation summaries with `include_groups` and `latest_has_media`, per-conversation messages by UUID or 1:1 remote key, mark read, admin search / update / delete. Some carriers may reject group chats or MMS from 10DLC numbers |
| iMessage | Send 1:1 messages or dedicated-outbound groups with `identity.send_imessage(to=[...])`, reply or retry failed creation by `conversation_id`, and opt groups into message/conversation lists with `include_groups=True`. `IMessageGroupCreationStatus` and `group_creation_status` expose `creating`, `not_created`, or `ready`; failed attempts stay in the same conversation. `send_style` works on group creation and replies and can accompany media. Existing message-ID reaction methods support inbound group messages, with nullable reaction `assignment_id`. Group read receipts and typing are not supported |
| Vault — secrets | Initialize with client-side encryption, unlock, CRUD all payload types (`login`, `api_key`, `key_pair`, `ssh_key`, `other`), metadata-only listing without unlock |
| Vault — identity-scoped | `identity.credentials` with typed per-type accessors (`get_login`, `get_api_key`, `get_ssh_key`, `get_key_pair`), filtered by access rules |
| TOTP | Store inside `LoginPayload.totp`, parse `otpauth://` URIs, generate codes client-side, set / remove on existing logins |
| Mailboxes (admin) | List, get, update display name, search, delete. The per-mailbox `filter_mode` update is **deprecated** — set `mail_filter_mode` on the identity instead |
| Phone numbers (admin) | Provision local numbers (with optional state), update incoming-call action (`webhook` / `auto_accept` / `auto_reject`), transcript search, release |
| Contact rules | Identity-keyed mail (exact email / domain) and phone (exact E.164) allow/block rules, addressed by agent handle (`mail_identity_contact_rules` / `phone_identity_contact_rules`, plus `identity.create_mail_contact_rule(...)` etc.); whitelist vs blacklist via the identity's `mail_filter_mode` / `phone_filter_mode`; action updates and duplicate-rule handling. The per-mailbox / per-number rule resources are **deprecated** |
| Contacts | CRUD with emails / phones / addresses, reverse-lookup variants, per-identity or wildcard access grants, bulk vCard import (≤5 MiB, ≤1000 cards), vCard 4.0 export |
| Notes | CRUD free-form notes, list and filter, per-identity access grants (no wildcard) |
| Whoami | Inspect caller's auth type (API key vs [Inkbox Console](https://inkbox.ai/console) user session), organization ID, API-key subtype constants for [admin vs agent-scoped](/docs/api-keys) branching |
| Webhooks | Per-identity signing keys — create / rotate / status by agent handle (`signing_keys.create_or_rotate` / `get_status`, plus `identity.create_signing_key()` / `identity.get_signing_key_status()`); the org-level signing key is **deprecated**. The first webhook subscription created for a keyless identity returns its signing secret **once**. Verify incoming requests via HMAC-SHA256 over `{request_id}.{timestamp}.{body}`; typed receiver-side payloads (`MailWebhookPayload`, `TextWebhookPayload`, `PhoneIncomingCallWebhookPayload`) with `event_type` discrimination across all six mail events and all five `text.*` lifecycle events; mail events carry `data.contacts: list[WebhookMailContact]` (`{bucket, address, id, name}`, sparse, with `MailContactBucket = "from" \| "to" \| "cc" \| "bcc"`) plus `bcc_addresses` on outbound; text events carry `conversation_id`, `sender_phone_number`, outbound `recipients[]`, and top-level `recipient_phone_number` for group lifecycle events, plus plural `data.contacts: list[WebhookContact]` (`{id, name, memories: list[str]}`) and `data.agent_identities: list[WebhookAgentIdentity]` (always present, possibly empty); inbound-call payloads expose the same plural pair at top level. Contact matches are organization-wide; agent-identity matches retain identity visibility filtering |
| Error handling | `InkboxAPIError` with status code and structured detail; narrower `DuplicateContactRuleError` subclass |
| Tunnels | `inkbox.tunnels.connect(tunnel_id=..., forward_to=...)` for a public `my-agent.inkboxwire.com` URL; URL forwarding or in-process ASGI app; edge vs passthrough TLS. Read-only API on tunnels (`list` / `get` / `update`) plus `connect()` and `sign_csr()` for passthrough; tunnel creation and deletion happen through the identity surface. POSIX-only data plane. |
## Install
```bash
# via skills CLI (recommended)
npx skills add inkbox-ai/inkbox/skills
# manual (Claude Code)
git clone https://github.com/inkbox-ai/inkbox
cp -r inkbox/skills/inkbox-python ~/.claude/skills/
```
## Prerequisites
- Python ≥ 3.11
- `inkbox` installed in your project (`pip install inkbox`)
- An [Inkbox](https://www.inkbox.ai) API key from the [Console](https://inkbox.ai/console/)
---
---
# Tunnels
description: Teach your coding agent how to expose a local server at a public URL via Inkbox tunnels — edge vs passthrough TLS, URL forwarding, and in-process handlers
---
# Tunnels Skill
This skill teaches your coding agent how to bring a local server online at a public `my-agent.inkboxwire.com` URL using Inkbox tunnels. Once installed, the agent knows how to call `inkbox.tunnels.connect(...)` in either Python or TypeScript, when to pick edge vs passthrough TLS, and how to forward to a local URL or run an in-process Fetch / ASGI / WebSocket handler. Tunnels are provisioned and destroyed via the [identity surface](/docs/api/identities) — every identity owns exactly one tunnel.
[View the source →](https://github.com/inkbox-ai/inkbox/tree/main/skills/inkbox-tunnels)
## What your agent learns
| Feature | Operations |
|---|---|
| Connect (Python) | `inkbox.tunnels.connect(name=..., forward_to=...)` — returns a `TunnelListener`; `wait()` / `close()` for sync, `serve_forever()` / `aclose()` for async |
| Connect (TypeScript) | `import { connect } from "@inkbox/sdk/tunnels/connect"` — Node-only subpath; returns `Promise`; `await listener.wait()` to block until shutdown |
| URL forwarding | Pass `forward_to` (Python) or `forwardTo` (TS) — defaults to loopback-only; opt into remote forwarding only after reviewing the SSRF tradeoff |
| In-process handler (TS) | Fetch-API `(req, ctx) => Response \| Promise` — `ctx` exposes `signal`, `forwardedForIp`, `sniHost`, and the read-only envelope |
| In-process handler (Python) | Pass an ASGI app callable as `forward_to` — works with FastAPI, Starlette, or any ASGI 3.0 framework; WebSocket scopes supported |
| WebSocket handler (TS) | `wsHandler: async (ws) => { await ws.accept(); for await (const msg of ws) { ... } }` — paired with an HTTP path |
| Edge vs passthrough TLS | Default `edge` — Inkbox terminates TLS at the edge. `passthrough` — your process terminates TLS; SDK auto-generates and signs the cert via the control plane |
| Provisioning | Tunnels are provisioned by `inkbox.create_identity(...)` / `inkbox.createIdentity(...)` — pass nested `tunnel: { tls_mode }` to customize. Read the tunnel back off `identity.tunnel` |
| Read-only API (Python) | `list` / `get` / `update` / `sign_csr` |
| Read-only API (TypeScript) | `list` / `get` / `update` / `signCsr` |
| Lifecycle | Deletion flows through `identity.delete()` — the linked tunnel is torn down with it. No grace window, no restore. `TunnelStatus` is `AWAITING_CERT` / `ACTIVE` |
| Data-plane auth | The data-plane hello carries `x-tunnel-id` (tunnel UUID) and `x-api-key` (the same REST key the rest of the SDK uses). No per-tunnel secret on disk |
| Common options (Python) | `pool_size` (1–32), `on_status` callback (`connecting` / `connected` / `reconnecting` / `closed`), body caps |
| Common options (TypeScript) | `poolSize` (1–32), `onStatus` callback, body caps, `installSignalHandlers` for clean shutdown (auto-installs only when no SIGINT/SIGTERM handlers are present) |
| Platform notes | Control-plane operations (`list` / `get` / `update` / `sign_csr`) work everywhere; `connect()` requires POSIX and raises on Windows |
## Install
```bash
# via skills CLI (recommended)
npx skills add inkbox-ai/inkbox/skills
# manual (Claude Code)
git clone https://github.com/inkbox-ai/inkbox
cp -r inkbox/skills/inkbox-tunnels ~/.claude/skills/
```
## Prerequisites
- A POSIX host (Linux, macOS, or WSL) — `connect()` is not supported on Windows
- Python ≥ 3.11 with `pip install inkbox`, or Node.js ≥ 22 with `npm install @inkbox/sdk` (`@inkbox/sdk` declares `engines.node ">=22"` because the tunnels data-plane subpath imports `node:http2` and related Node-only modules)
- An [Inkbox](https://www.inkbox.ai) API key from the [Console](https://inkbox.ai/console/)
---
---
# Agent self-signup
description: Teach your coding agent the Inkbox agent self-signup flow — register, verify, resend, check status
---
# Agent self-signup Skill
This skill is an SDK-agnostic reference for the Inkbox agent self-signup flow. Once installed, your coding agent knows how to register a new Inkbox account without a pre-existing API key — provisioning a mailbox, identity, and API key in a single call, then verifying with a 6-digit code the human receives by email.
[View the source →](https://github.com/inkbox-ai/inkbox/tree/main/skills/inkbox-agent-self-signup)
## What your agent learns
| Feature | Operations |
|---|---|
| Four-step flow | Register (public, no auth) → Verify when needed → Resend verification → Check status |
| Restrictions — unclaimed | 5 recipient sends per fixed 24-hour window, recipients limited to `human_email`, cannot create additional identities |
| Restrictions — claimed | Plan-based organization recipient-send limits, no recipient restriction, cannot create additional identities |
| Python SDK methods | Class methods `Inkbox.signup`, `Inkbox.verify_signup`, `Inkbox.resend_signup_verification`, `Inkbox.get_signup_status` — no instance required |
| TypeScript SDK methods | Static methods `Inkbox.signup`, `Inkbox.verifySignup`, `Inkbox.resendSignupVerification`, `Inkbox.getSignupStatus` — no instance required |
| Request fields | Required: `human_email`, `note_to_human`; optional: `display_name`, `agent_handle`, `email_local_part`, `harness` (the coding agent the agent runs in, e.g. `claude-code`, `codex`, `openclaw`, `opencode`, `hermes`), `invitation_token` (an A2A connection invitation; omit it when you do not have one) |
| Response fields | `api_key` (shown once), `email_address`, `agent_handle`, `organization_id`, `claim_status`, optional A2A `invitation` summary |
| Verification semantics | 6-digit code, 48-hour expiry, max 5 attempts before a resend is required; resend has a 5-minute cooldown. After signup or verification returns a claimed identity, its `message` includes matching plugin install, bootstrap, and doctor commands when available — ask your human for permission before acting on them |
| Direct API (curl) | `POST /api/v1/agent-signup`, `/verify`, `/resend-verification`, `GET /status` with `X-API-Key` header |
| Operational notes | Save `api_key` immediately (shown only once); `organization_id` may change after verification — always prefer the most recent value |
An A2A invitation may change the flow: include it in the initial signup rather
than registering first and requesting a verification code. SDK and CLI signup
accept either its link or raw token through their invitation fields, while direct
REST uses the raw token in `invitation_token`. An email-bound invitation with a
matching human email auto-claims and connects the identity without a second
email, while a manual-handoff invitation reserves the connection until ordinary
verification succeeds. See [Connection
invitations](/docs/api/a2a/invitations#accept-during-self-signup).
> Ordinary signup sends a real verification email, while invitation-assisted signup may connect immediately. Your agent should always confirm with the user before initiating signup.
## Install
```bash
# via skills CLI (recommended)
npx skills add inkbox-ai/inkbox/skills
# manual (Claude Code)
git clone https://github.com/inkbox-ai/inkbox
cp -r inkbox/skills/inkbox-agent-self-signup ~/.claude/skills/
```
## Prerequisites
- No API key required to register — the `POST /api/v1/agent-signup` endpoint is public
- The Python or TypeScript SDK installed in your project if you plan to use the SDK methods (`pip install inkbox` / `npm install @inkbox/sdk`)
- A valid email address for the human who will approve the signup
---
---
# CLI
description: Teach your coding agent the Inkbox CLI (@inkbox/cli) for shell-native workflows
---
# CLI Skill
This skill teaches your coding agent how to drive the entire Inkbox API from a shell using the `@inkbox/cli` tool. Once installed, the agent knows which commands exist, which flags they take, which require a vault key, and which are high-risk and need user confirmation before running.
[View the source →](https://github.com/inkbox-ai/inkbox/tree/main/skills/inkbox-cli)
## What your agent learns
| Feature | Operations |
|---|---|
| Auth & runtime | `--api-key` / `INKBOX_API_KEY` / `~/.inkbox/config`, `--vault-key` / `INKBOX_VAULT_KEY`, `--base-url`, `--json` for parseable output |
| Install modes | Global (`npm install -g @inkbox/cli`), one-shot (`npx @inkbox/cli `), local repo dev (`npm --prefix cli run dev -- `) |
| High-risk operations | Commands that send real traffic or mutate resources — `signup create`, `email send`, `phone call`, `text send`, `mailbox imports run` / `cancel`, `identity delete`, `email delete`, `vault delete`, `number release`, `identity update --mail-filter-mode` / `--phone-filter-mode`, `identity signing-key rotate` (and the deprecated `mailbox update --filter-mode` / `number update --filter-mode` / `signing-key create`) |
| Agent self-signup | `inkbox signup create`, `signup verify`, `signup resend-verification`, `signup status` — with the issued API key passed back via `--api-key` or `INKBOX_API_KEY` |
| Identities | `identity list` / `get` / `create [--display-name] [--description] [--email-local-part] [--tls-mode edge\|passthrough]` / `delete` / `update [--display-name] [--description] [--clear-description] [--new-handle] [--mail-filter-mode whitelist\|blacklist] [--phone-filter-mode whitelist\|blacklist]` / `refresh` |
| Tunnels | `tunnel list` / `tunnel get ` / `tunnel update [--metadata]` / `tunnel sign-csr --csr [--out ]` — tunnels are provisioned and destroyed via the identity surface |
| Identity-scoped secrets | `identity create-secret` / `get-secret` / `delete-secret` / `revoke-access` / `set-totp` / `remove-totp` / `totp-code` (requires vault key) |
| Email | `email send` / `list` / `get` / `search` / `unread` / `mark-read` / `delete` / `delete-thread` / `star` / `unstar` / `thread` — all identity-scoped via `-i ` |
| Phone | `phone call` / `calls` / `transcripts` / `search-transcripts` — identity-scoped |
| Text messages | `text send (--to \| --conversation-id ) [--text] [--media-url]` for 1:1 SMS/MMS, beta group MMS, and conversation replies; `list` / `get` / `conversations --include-groups` / `conversation ` / `search` / `mark-read` / `mark-conversation-read ` — identity-scoped. Some carriers may reject group chats or MMS from 10DLC numbers |
| iMessage | `imessage send (--to \| --conversation-id ) [--media-url ] [--send-style