API tools (MCP)
The Platform API (/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
Section titled “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:
{ "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 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.
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
Section titled “Connect Claude Code”Add the server, then sign in with your Omni Octo account:
claude mcp add --transport http omniocto https://api.omniocto.com/mcpStart 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). What the page shows and how to revoke it later:
Connect.
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:
{ "mcpServers": { "omniocto": { "type": "http", "url": "https://api.omniocto.com/mcp" } }}With an API key
Section titled “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.
export OMNI_OCTO_API_KEY=sk-octo_... # Settings → API Keys & Webhooksclaude mcp add --transport http omniocto https://api.omniocto.com/mcp \ --header "Authorization: Bearer $OMNI_OCTO_API_KEY"claude mcp list # omniocto: ... - ✓ ConnectedA 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):
{ "mcpServers": { "omniocto": { "type": "http", "url": "https://api.omniocto.com/mcp", "headers": { "Authorization": "Bearer ${OMNI_OCTO_API_KEY}" } } }}Connect Codex
Section titled “Connect Codex”Add the server, then sign in:
codex mcp add omniocto --url https://api.omniocto.com/mcpcodex mcp login omniocto # opens your browsercodex mcp list # lists omniocto and its URLcodex 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
Section titled “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:
export OMNI_OCTO_API_KEY=sk-octo_... # Settings → API Keys & Webhookscodex mcp add omniocto --url https://api.omniocto.com/mcp \ --bearer-token-env-var OMNI_OCTO_API_KEYcodex mcp list # lists omniocto and its URLExport the variable in the shell you start Codex from (or in your shell profile): without it, Codex has no key to send.
Other clients
Section titled “Other clients”A client that supports MCP’s OAuth sign-in needs only the URL; what it must
support is on Connect. 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
Section titled “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. 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
Section titled “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:
{ "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_toolandconfirm_action(mcp__omniocto__run_tool,mcp__omniocto__confirm_action). Never add them topermissions.allow.find_toolsanddescribe_toolonly search and read, and are safe to allow. - Claude Code shows approval forms, so the tools that take
confirmalso ask you there, during the call, whateverpermissions.allowsays. --dangerously-skip-permissionsremoves the client’s prompt; only the approval form remains.- Text from outside can try to steer the model.
get_call’s andlist_calls’extractedhold what the other person said on a call,list_campaign_recipients’last_replyis what a person wrote back, and the inbox tools return what people wrote, andget_agentan 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
Section titled “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
Section titled “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 exactly as /v1 sent it.
Branch on error.code. On 429 the line adds “Wait N seconds, then retry”.
Polling
Section titled “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
Section titled “Worked example: a text, then its delivery”You: Send a test SMS to my phone, +14155550100, and tell me when it is delivered.
list_senders {}→+16285550100, SMSready.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.send_message { …same…, confirm: true, idempotency_key: "<suggested>" }→202,{ "id": "8f1e…", "status": "queued" }.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
Section titled “Worked example: a 3-person campaign”You: Draft, preview and start an SMS campaign to these three numbers: …
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." } }→ adraft,id: "ca-…".preview_campaign { id: "ca-…" }→recipients: 3,cost_credits, three samples,id: "snap_…".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.”- You approve;
start_campaign { …, confirm: true, idempotency_key: "<suggested>" }→status: "queued". get_campaign { id: "ca-…" }→running, thencompleted, withcounts.
If the campaign was edited after the preview, step 4 is 409 preview_stale:
preview again.
Limits
Section titled “Limits”- Every
/v1request a tool makes counts against your key’s (or your sign-in’s) rate limits (300 reads and 100 writes a second) and the SMS send limit. Most tools make one request;start_campaignwithoutconfirmmakes two reads;get_messageandget_callwithwait_secondsmake up to 13 reads;preview_campaignmakes one write and up to 11 reads while an SMS preview builds. /mcpitself 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
Section titled “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 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. |
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. |