# API tools (MCP)

> The Platform API (/v1) as tools on the Omni Octo MCP server at /mcp — send texts and WhatsApp templates, place AI-agent calls, work the inbox, keep contacts and lists, and run campaigns from Claude Code, Codex or any MCP client, signed in with your account or with an API key.

The [Platform API](/platform-api/overview/) (`/v1`) as MCP tools, on the one Omni Octo
MCP server, `https://api.omniocto.com/mcp` (older URLs `/mcp/api` and
`/mcp/tools` are the same server).

Your agent (Claude Code, Codex, Cursor, your own MCP client) finds them with
`find_tools`, reads one with `describe_tool`, and runs it with `run_tool`,
signed in with your Omni Octo account or with a workspace API key. Each tool
makes the same `/v1` request you would make yourself: the same validation, the
same errors, the same rate limits, the same idempotency.

## How your agent reaches them

The server does not list every API tool: it lists `send_sms` and `place_call`
by name, and four tools that reach the rest, a few thousand tokens of your
client's context. A call by name looks like this:

```json
{ "name": "run_tool", "arguments": { "name": "list_senders", "arguments": {} } }
```

`describe_tool` returns a tool's input schema, its MCP hints (read-only,
destructive) and how it is approved. The same server also reaches
[Octo's tools](/mcp/tools/) and the docs search; where a name is both an API
tool and one of Octo's, `/mcp` runs the API tool, so every flow on this page
stays whole (for example `preview_campaign` → `start_campaign` with the
`preview_id`). See [MCP server overview](/mcp/overview/).

> **Live only.** There is no test mode yet: every message and call reaches a
> real person and spends credits. While building, send only to numbers you
> own.

## Connect Claude Code

Add the server, then sign in with your Omni Octo account:

```bash
claude mcp add --transport http omniocto https://api.omniocto.com/mcp
```

Start Claude Code. Your browser opens on the Omni Octo sign-in page; if it
does not, type `/mcp` in Claude Code, pick `omniocto` and choose
**Authenticate**. In the browser, sign in to the dashboard if asked, choose the
workspace (when you have more than one) and click **Allow**. Back in Claude
Code, `/mcp` shows `omniocto` connected, with its six tools, which reach
every API tool ([the full list](#the-tools)). What the page shows and how to revoke it later:
[Connect](/mcp/connect/#what-you-see-when-you-sign-in).

To share the setup with a project, commit a `.mcp.json` with the URL only;
each person who opens the project signs in as themselves:

```json
{
  "mcpServers": {
    "omniocto": {
      "type": "http",
      "url": "https://api.omniocto.com/mcp"
    }
  }
}
```

### With an API key

For scripts, CI or a machine with no browser, send a workspace API key
instead. Keep the key out of your shell history and out of git: put it in an
environment variable, then add the server.

```bash
export OMNI_OCTO_API_KEY=sk-octo_...   # Settings → API Keys & Webhooks
claude mcp add --transport http omniocto https://api.omniocto.com/mcp \
  --header "Authorization: Bearer $OMNI_OCTO_API_KEY"
claude mcp list                        # omniocto: ... - ✓ Connected
```

A server added with the header never asks you to sign in. In a shared
`.mcp.json`, read the key from the environment (never commit the key itself):

```json
{
  "mcpServers": {
    "omniocto": {
      "type": "http",
      "url": "https://api.omniocto.com/mcp",
      "headers": { "Authorization": "Bearer ${OMNI_OCTO_API_KEY}" }
    }
  }
}
```

## Connect Codex

Add the server, then sign in:

```bash
codex mcp add omniocto --url https://api.omniocto.com/mcp
codex mcp login omniocto               # opens your browser
codex mcp list                         # lists omniocto and its URL
```

`codex mcp login` opens the same sign-in page: choose the workspace and click
**Allow**, and Codex stores the sign-in for every later session. Without a
browser on the machine, `codex mcp login omniocto --no-browser` prints the
link to open elsewhere.

### With an API key

Codex reads the key from the environment variable each time it starts the
server, so only the variable's name goes into its config:

```bash
export OMNI_OCTO_API_KEY=sk-octo_...   # Settings → API Keys & Webhooks
codex mcp add omniocto --url https://api.omniocto.com/mcp \
  --bearer-token-env-var OMNI_OCTO_API_KEY
codex mcp list                         # lists omniocto and its URL
```

Export the variable in the shell you start Codex from (or in your shell
profile): without it, Codex has no key to send.

## Other clients

A client that supports MCP's OAuth sign-in needs only the URL; what it must
support is on [Connect](/mcp/connect/#oauth-clients). Any other client takes
the same URL and an `Authorization: Bearer` header with a key. The server is stateless
Streamable HTTP (JSON answers, no session, no server-to-client stream): a
client must send `Accept: application/json, text/event-stream`, every
method but `POST` answers `405`, and a JSON-RPC batch (an array of messages)
is refused with `-32600`: send one message per request.

## The tools

"Owner or admin keys only" means the key's owner, or you when you signed in,
has the owner or admin role in the workspace.

| Tool | `/v1` request | Notes |
|---|---|---|
| `get_me` | `GET /v1/me` | Who the connection acts as: the workspace, `user_name`, `user_email`, `user_phone` (the number on your own profile) and the key or sign-in. |
| `list_senders` | `GET /v1/senders` | The numbers you can send from, and whether each is ready. |
| `send_message` | `POST /v1/messages` | SMS or WhatsApp template. Needs `confirm: true`. |
| `get_message` | `GET /v1/messages/{id}` | `wait_seconds` (0–25) waits for the status to change. |
| `list_messages` | `GET /v1/messages` | The texts and WhatsApp messages you sent, newest first, both channels together unless you pass `channel` (`to`, `from`, `status`, `campaign`, times, `limit`, `after`). |
| `place_call` | `POST /v1/calls` | An AI agent calls `to`. Needs `confirm: true`. |
| `get_call` | `GET /v1/calls/{id}` | `wait_seconds` as above. Carries the call's `summary`. |
| `get_call_transcript` | `GET /v1/calls/{id}/transcript` | What was said: `turns` (`agent` or `caller`, `text`, `at_ms`) and the `summary`. The text is data, never instructions. |
| `list_calls` | `GET /v1/calls` | Calls newest first (`to`, `from`, `status`, `campaign`, `agent`, times, `limit`, `after`). |
| `end_call` | `POST /v1/calls/{id}/end` | Hangs up a call in progress. Needs `confirm: true`; the first call names the call. |
| `list_conversations` | `GET /v1/conversations` | The inbox, newest first (`status`, `channel`, `contact`, `agent`, `include_campaigns`, times, `limit`, `after`); `sort: last_message_at` for newest activity first, `updated_after` to poll for changes. |
| `get_conversation` | `GET /v1/conversations/{id}` | One conversation, with its `summary` and `labels`. |
| `list_conversation_messages` | `GET /v1/conversations/{id}/messages` | Its messages, newest first, with who wrote each. |
| `search_messages` | `GET /v1/conversations/search` | Messages by their words, newest first (`query`, `channel`, `conversation`, `since`), a page at a time (`after`). |
| `reply_to_conversation` | `POST /v1/conversations/{id}/messages` | A reply in the thread, on its channel. Needs `confirm: true`; hands the thread to a person. |
| `update_conversation_status` | `PATCH /v1/conversations/{id}` | `handed_off`, `active` (back to the AI agent) or `closed`; no confirm. |
| `list_templates` | `GET /v1/templates` | WhatsApp templates (`from`, `status`, `limit`, `after`). |
| `get_template` | `GET /v1/templates/{id}` | One WhatsApp template, any status, with every button and its address (`all_buttons`). |
| `list_sms_templates` | `GET /v1/sms-templates` | The SMS templates saved in the dashboard (`limit`, `after`). |
| `get_sms_template` | `GET /v1/sms-templates/{id}` | One SMS template: its `text` and `variables`. |
| `list_voicemail_recordings` | `GET /v1/voicemail-recordings` | The audio a voice campaign or call can play when voicemail answers (`limit`, `after`). Uploading one is REST only. |
| `list_agents` | `GET /v1/agents` | `limit`, `after`. |
| `get_agent` | `GET /v1/agents/{id}` | One agent: its `prompt`, `greeting`, `model`, `voice`, `language`, `prompt_version`, and the `variables` its greeting and prompt use. |
| `update_agent` | `PATCH /v1/agents/{id}` | The agent's `prompt`, `greeting`, `model`, `voice` or `language`. Needs `confirm: true` and `expected_version`: the first call says what would change and names the version. Owner or admin keys only. |
| `list_agent_prompt_versions` | `GET /v1/agents/{id}/prompt-versions` | The agent's versions, newest first, with `source` and what `changed` (`limit`, `after`). |
| `get_agent_prompt_version` | `GET /v1/agents/{id}/prompt-versions/{version}` | One version with its texts, models, voice and language. |
| `list_agent_phrase_rules` | `GET /v1/agents/{id}/phrase-rules` | The agent's phrase rules (fixed replies for set phrases), red flags first, with every phrase and reply. |
| `create_agent_phrase_rule` | `POST /v1/agents/{id}/phrase-rules` | Add a phrase rule: phrases per language, the reply sent word for word, the Live signal to raise. The first call answers `confirmation_required` with every phrase and reply; nothing is saved until `confirm: true`. |
| `update_agent_phrase_rule` | `PATCH /v1/agents/{id}/phrase-rules/{rule_id}` | Change a rule, or switch it off (`enabled: false`). The first call shows the rule before and after; `confirm: true` with `expected_version` changes it. |
| `delete_agent_phrase_rule` | `DELETE /v1/agents/{id}/phrase-rules/{rule_id}` | Delete a rule after `confirm: true`; its past matches stay recorded. |
| `test_agent_turn` | `POST /v1/agents/{id}/test-turns` | Rehearse the agent in text: you play the person on the line (`message`), it answers (`reply`); `session_id` continues, `mission` and `variables` set the call. Calls nobody and stores no conversation; bills the agent's model tokens. No confirm; `idempotency_key` makes a retry free. |
| `list_segments` | `GET /v1/segments` | Saved segments, for `audience.segment` (`limit`, `after`). |
| `get_segment` | `GET /v1/segments/{id}` | One segment, with `contacts`: how many it matches now. |
| `list_segment_contacts` | `GET /v1/segments/{id}/contacts` | The contacts a segment matches now, newest first (`limit`, `after`). |
| `list_contact_lists` | `GET /v1/lists` | `limit`, `after`. |
| `list_contact_list_contacts` | `GET /v1/lists/{id}/contacts` | The contacts in one list (`limit`, `after`). |
| `list_contacts` | `GET /v1/contacts` | Contacts newest first, who has a number or email (`phone`, `email`), or a search (`query`); `limit`, `after`. |
| `get_contact` | `GET /v1/contacts/{id}` | One contact, with its tags and `dnc`: its opt-outs per channel. |
| `get_contact_channels` | `GET /v1/contacts/{id}/channels` | Every phone number and email of one contact, a page at a time (`limit`, `after`). |
| `list_contact_fields` | `GET /v1/contact-fields` | The custom fields: the keys of `attributes`. |
| `create_contact` | `POST /v1/contacts` | One contact. Needs an `idempotency_key`. |
| `update_contact` | `PATCH /v1/contacts/{id}` | Only the fields you pass change. |
| `tag_contact` | `POST /v1/contacts/{id}/tags` | A workflow that starts on a tag starts. |
| `untag_contact` | `DELETE /v1/contacts/{id}/tags/{tag}` | One tag. |
| `opt_out_contact` | `POST /v1/contacts/{id}/dnc` | Always `opted_out`: opting back in is left to a person. |
| `list_contact_consent_events` | `GET /v1/contacts/{id}/consent-events` | The consent record, newest first, with each notice's text as sent (`limit`). |
| `record_contact_consent` | `POST /v1/contacts/{id}/consent-events` | `recorded_by_operator` or `withdrawn`, by hand. `withdrawn` also opts that channel out. |
| `get_contact_list` | `GET /v1/lists/{id}` | One list and its number of contacts. |
| `create_contact_list` | `POST /v1/lists` | A list with its `contacts` and/or `contact_ids` you have, for `audience.list`. Sends nothing. |
| `add_contacts_to_list` | `POST /v1/lists/{id}/contacts` | New `contacts` rows, or `contact_ids` you have. Needs an `idempotency_key`. |
| `rename_contact_list` | `PATCH /v1/lists/{id}` | A new `name`; nothing else changes. No confirm. |
| `delete_contact_list` | `DELETE /v1/lists/{id}` | Needs `confirm: true`; the first call names the list and its count. |
| `create_segment` | `POST /v1/segments` | A saved filter (`name`, `filter`); answers its `contacts` count. Owner or admin key. |
| `update_segment` | `PATCH /v1/segments/{id}` | New `name` and/or the whole `filter`; a new filter is refused while a running campaign uses it. |
| `delete_segment` | `DELETE /v1/segments/{id}` | Needs `confirm: true`; the first call names the segment and its count. |
| `list_campaigns` | `GET /v1/campaigns` | Campaigns newest first (`channel`, `status`, `limit`, `after`); `counts` is `null` here, read one with `get_campaign`. |
| `create_campaign_draft` | `POST /v1/campaigns` with `start: "draft"` | Always a draft: it sends nothing. Inline `contacts` / `csv` are imported into your contacts. |
| `update_campaign_draft` | `PATCH /v1/campaigns/{id}` | A draft or a paused campaign; only the fields you pass change. Sends nothing, and makes every earlier preview stale. `insight` alone also changes on a queued or running campaign. |
| `delete_campaign_draft` | `DELETE /v1/campaigns/{id}` | A campaign that never sent anything. Needs `confirm: true`; the first call names the campaign. |
| `list_insights` | `GET /v1/insights` | What calls find out: each insight's `field_keys` and where it is `attached` (`limit`, `after`). |
| `get_insight` | `GET /v1/insights/{id}` | One insight with its `fields` and `revision`. A call's answers are get_call's `extracted`. |
| `create_insight` | `POST /v1/insights` | Typed fields a call should find out. Needs an `idempotency_key`; no confirm (it runs on no call until attached with `insight`). Owner or admin keys only. |
| `update_insight` | `PATCH /v1/insights/{id}` | Changes what every agent and campaign using it finds out from now on. Needs `confirm: true` and `expected_revision`; the first call says what would change. Owner or admin keys only. |
| `get_compliance` | `GET /v1/compliance` | Whether the outbound compliance acknowledgment a first campaign start needs is accepted. Accepting it is left to a person. |
| `get_balance` | `GET /v1/balance` | Credits, what can be spent now, and whether the workspace is suspended. |
| `get_usage` | `GET /v1/usage` | What the credits went on in a window: the total, by category, day or label. |
| `preview_campaign` | `POST /v1/campaigns/{id}/previews` | Who gets it, who is skipped, the cost, samples. |
| `start_campaign` | `POST /v1/campaigns/{id}/start` | Needs `preview_id` (on every channel, unlike `/v1`, where WhatsApp and voice may start without one) and `confirm: true`. |
| `get_campaign` | `GET /v1/campaigns/{id}` | Status and counts. |
| `get_campaign_usage` | `GET /v1/campaigns/{id}/usage` | What it cost so far, by category, its calls or messages, and an AI-call campaign's spending limit (`spend_cap`, with the `dashboard_url` where a person raises it). |
| `list_campaign_recipients` | `GET /v1/campaigns/{id}/recipients` | Each person and what happened to them (`status`, `limit`, `after`), with `last_reply`. |
| `get_campaign_recipient` | `GET /v1/campaigns/{id}/recipients/{recipient_id}` | One person by the list's `id` or `cr-…`; a voice recipient with its call's `transcript`, `extracted` and `summary`. |
| `get_campaign_results` | `GET /v1/campaigns/{id}/results` | A voice round in counts: by `status`, `outcome` and `prompt_version`, `reached`, `unreached`, and what the Dialog Insight extracted per field. |
| `clone_campaign` | `POST /v1/campaigns/{id}/clone` | The next round of a voice campaign: `audience` (`unreached` or `same`), `start`, and any field `update_campaign_draft` takes as an override. Needs an `idempotency_key`; `start: "auto"` also needs `confirm: true` (the first call says whom it would call). |
| `wait_for_events` | `GET /v1/events?wait=` | Waits up to `timeout_s` (25) for events instead of reading in a loop: `after` (leave it out on the first call, then pass `next_cursor`), `types`, `campaign`, `limit` (25). Always v2. Owner or admin keys only. |
| `pause_campaign` | `POST /v1/campaigns/{id}/pause` | A stop button; no confirm. Resume with `resume_campaign`. |
| `resume_campaign` | `POST /v1/campaigns/{id}/resume` | Needs `confirm: true` and an `idempotency_key`; the first call names the campaign, why it is paused and who is left. `preview_id` for an SMS campaign or one edited while paused. |
| `list_webhook_deliveries` | `GET /v1/webhooks/{id}/deliveries` | What one webhook endpoint was sent in the last 7 days and what it answered (`status`, `limit`, `after`). Owner or admin keys only. |

Each tool's input schema is the `/v1` field's own JSON Schema (types, enums,
limits, nested `template`, `audience`, `schedule`, `content`), read from the
[OpenAPI document](/openapi/v1/). Only `confirm`, `idempotency_key` and
`wait_seconds` are the server's own; they never reach `/v1`, and passed to
a tool that does not take them they are refused (`unknown_parameter`). A
field a write tool does not list still goes to `/v1`, which refuses it with
`400 unknown_field` (a misspelled `send_at` is an error, never silently
dropped); a read tool refuses an argument it does not take itself. The one
exception is `start` on `create_campaign_draft`: it is always set to
`"draft"`, whatever you pass.

## Confirm is not a human gate

**If your MCP client supports elicitation** (it declares the `elicitation`
capability), approval is a form your client shows you, not something the model
can answer. Every tool that takes `confirm` asks you in that form during the
call, with exactly what would happen; it runs only if you approve. `confirm`
is ignored, so the model cannot approve by itself. Declining answers
`declined_by_user`; closing the form answers `approval_not_answered`; nothing
is sent either way. Such a client gets a session (`Mcp-Session-Id`) for this;
every other client stays stateless.

**Without elicitation**, `send_message`, `place_call`, `reply_to_conversation`
and `start_campaign` do nothing unless `confirm` is exactly `true`. Without it they answer "Needs the user's OK"
with what would happen (the recipient and text; for a campaign its name,
sender, recipient count, `cost_credits` and schedule) and a
`suggested_idempotency_key`. It is a normal result (`isError: false`), so
clients do not show it as a failed call; the details are in
`structuredContent`:

```json
{
  "confirmation": {
    "code": "confirmation_required",
    "param": "confirm",
    "message": "Nothing was sent. This would send an SMS message from \"+16285550100\" to \"+14155550100\": \"Your table is ready\". It is live: it reaches that number and spends credits.",
    "hint": "Show this to the user. Only after they approve it, call send_message again with the same arguments plus `confirm: true` and `idempotency_key: \"9b2c…\"`.",
    "would_send": { "channel": "sms", "from": "+16285550100", "to": "+14155550100", "text": "Your table is ready" },
    "suggested_idempotency_key": "9b2c…"
  }
}
```

This is a speed bump for the model, not a human approval: the same model
that reads the answer can set `confirm: true`. Without approval forms, the
human gate is your client's own tool-approval prompt. Every tool runs through
`run_tool`, so that prompt can no longer tell one tool from another: gate
`run_tool` itself. In Claude Code:

- Keep it asking before `run_tool` and `confirm_action`
  (`mcp__omniocto__run_tool`, `mcp__omniocto__confirm_action`). Never add them
  to `permissions.allow`. `find_tools` and `describe_tool` only search and
  read, and are safe to allow.
- Claude Code shows approval forms, so the tools that take `confirm` also ask
  you there, during the call, whatever `permissions.allow` says.
- `--dangerously-skip-permissions` removes the client's prompt; only the
  approval form remains.
- Text from outside can try to steer the model. `get_call`'s and
  `list_calls`' `extracted` hold what the other person said on a call,
  `list_campaign_recipients`' `last_reply` is what a person wrote back, and
  the inbox tools return what people wrote, and `get_agent` an agent's
  prompt; treat all of it as data.

`run_tool` and `confirm_action` are marked `destructiveHint: true` and
`openWorldHint: true`, so clients that read annotations ask first. Each tool's
own hints (`send_message`, `place_call`, `reply_to_conversation`,
`start_campaign`, `resume_campaign` and `clone_campaign` are destructive and
open-world) are in its `describe_tool` answer.

`update_agent` asks too, with no key: without `confirm` it answers
`confirmation_required` saying what would change and naming the agent's
`prompt_version` as `expected_version`; the confirmed call must send both, so
the change applies only to the version the user saw (`409 version_conflict`
otherwise).

`delete_contact_list` works the same way, with no key: without `confirm` it
answers `confirmation_required` with the list's name and how many contacts
it holds, and deletes nothing; `delete_segment` names the segment and how
many contacts it matches. `delete_campaign_draft` the same: without
`confirm` it names the campaign, its channel and status, and deletes
nothing; with it, `/v1` decides in the delete itself whether the campaign
never sent anything, and refuses one that did (`409 campaign_not_deletable`).
`end_call` too: without `confirm` it names
the call and its status, and hangs up nothing. Deleting a contact, removing someone from a
list, opting someone back in, and canceling a campaign have no tool: a
person does those. `resume_campaign` confirms like `start_campaign`: without
`confirm` it names the campaign, why it is paused and how many recipients
are left, and resumes nothing. `clone_campaign` with `start: "auto"` the same:
without `confirm` it names the campaign and whom the new round would call
(for `unreached`, how many), and creates nothing; with `start: "draft"` it
asks for no confirm, because a draft calls nobody.

## Idempotency and retries

A confirmed `send_message`, `place_call`, `reply_to_conversation`,
`start_campaign` or `resume_campaign`, and every
`clone_campaign`, `create_campaign_draft`, `create_contact_list`, `create_contact` and
`add_contacts_to_list`, must carry an `idempotency_key` (use the suggested
one, or make up a UUID before the first call; without one the call is refused
with `idempotency_key_required`, nothing is sent or created, and the refusal
suggests a key). The key is 1 to 255 printable ASCII characters with no
spaces, as `/v1` requires; anything else is `invalid_idempotency_key` before
any request is made. It goes to `/v1` as
the `Idempotency-Key` header. After a timeout, a `5xx` or a `429`, call
again with the same key: the API never acts twice for one key, and a replay
is marked `"idempotency_replayed": true` under `_mcp`.
A preview takes no key: a second preview makes a new
`preview_id`, and the newest one is the one to start.

## Results and errors

A result's first text block is the `/v1` JSON (also in `structuredContent`,
untouched). A second block carries `_mcp`: the HTTP `status`, the
`request_id` (the same id `/v1` and its logs use), and `idempotency_key`,
`idempotency_replayed`, `retry_after` or `waited_seconds` when they apply.

A `/v1` refusal is a tool error (`isError: true`) whose first line reads
`<status> <code>: <message> Hint: <hint>` and whose `structuredContent` is
the [error envelope](/platform-api/errors/) exactly as `/v1` sent it.
Branch on `error.code`. On `429` the line adds "Wait N seconds, then retry".

## Polling

Many clients cannot sleep between calls, so `get_message` and `get_call`
wait for you: with `wait_seconds` (up to 25) the server re-reads every 2
seconds and answers when the status changes or is final, or when the time is
up. A refusal while waiting (a `429`, a `500`) is answered as it is. If a
read stalls, the wait ends with the last good answer (or `504
upstream_timeout` when the first read stalled), and a client that hangs up
ends the wait at once. `preview_campaign` waits the same way, up to 20 s,
for an SMS preview that is still building. An SMS can stay `sent` when the carrier never reports delivery; stop
after about two minutes. `unconfirmed` means do not resend. Read a campaign
with `get_campaign` a minute or more apart.

## Worked example: a text, then its delivery

> **You:** Send a test SMS to my phone, +14155550100, and tell me when it is delivered.

1. `list_senders {}` → `+16285550100`, SMS `ready`.
2. `send_message { channel: "sms", from: "+16285550100", to: "+14155550100", text: "Test from Octo" }`
   → `confirmation_required`. Claude shows the text and asks you; Claude Code
   asks you to approve the next call.
3. `send_message { …same…, confirm: true, idempotency_key: "<suggested>" }`
   → `202`, `{ "id": "8f1e…", "status": "queued" }`.
4. `get_message { id: "8f1e…", wait_seconds: 25 }` → `sent`, then again →
   `delivered`. "Delivered at 14:02."

When you say "me", "myself" or "my phone" without a number, Claude calls
`get_me` and sends to its `user_phone`. When `user_phone` is `null`, Claude
asks you for the number; save it in the dashboard under **Account** so it is
there next time.

## Worked example: a 3-person campaign

> **You:** Draft, preview and start an SMS campaign to these three numbers: …

1. `create_campaign_draft { idempotency_key: "<a UUID>", channel: "sms", name: "Pickup reminder", from: "+16285550100", audience: { contacts: [{ phone: "+1…" }, { phone: "+1…" }, { phone: "+1…" }] }, content: { text: "Your order is ready for pickup." } }`
   → a `draft`, `id: "ca-…"`.
2. `preview_campaign { id: "ca-…" }` → `recipients: 3`, `cost_credits`,
   three samples, `id: "snap_…"`.
3. `start_campaign { id: "ca-…", preview_id: "snap_…" }` →
   `confirmation_required`: "Starting campaign "Pickup reminder" (sms, …) would
   send from "+16285550100" to 3 recipients, costing about … credits, starting
   as soon as it is started."
4. You approve; `start_campaign { …, confirm: true, idempotency_key: "<suggested>" }`
   → `status: "queued"`.
5. `get_campaign { id: "ca-…" }` → `running`, then `completed`, with
   `counts`.

If the campaign was edited after the preview, step 4 is `409 preview_stale`:
preview again.

## Limits

- Every `/v1` request a tool makes counts against your key's (or your
  sign-in's) [rate limits](/platform-api/retries/#rate-limits) (300 reads and 100 writes
  a second) and the SMS send limit. Most tools make one request;
  `start_campaign` without `confirm` makes two reads; `get_message` and
  `get_call` with `wait_seconds` make up to 13 reads; `preview_campaign`
  makes one write and up to 11 reads while an SMS preview builds.
- `/mcp` itself allows 600 requests a minute per IP address, checked
  before the key or sign-in.
- A request body is capped like `POST /v1/campaigns` (`413 payload_too_large`).

## Troubleshooting

| Answer | Why |
|---|---|
| `401 invalid_api_key` | The key in the `Authorization: Bearer` header is wrong or revoked. Fix the key, or remove the header and [sign in](#connect-claude-code) instead. |
| `401` with `error="invalid_token"` | Signed in, but the sign-in has ended: revoked in Settings → Connected apps, unused for 90 days, or you left the workspace. [Sign in again](/mcp/connect/#signing-in-again). |
| `404` | Wrong host or path: `/mcp` answers only on `api.omniocto.com`, not on the dashboard host, and a misspelled path under `/mcp/` is a `404` too. |
| `406` | The client did not send `Accept: application/json, text/event-stream`. |
| `405` | Any method but `POST`: this server is stateless. |
| `429 rate_limited` | Wait `retry_after` seconds (in `_mcp`), then retry with the same `idempotency_key`. |