documentation

MCP clients

Hail exposes MCP as a remote server. Hail Cloud uses OAuth. Paste the URL into your client. Click Allow in the browser. Your agent then gets the call, SMS, email, agent, number and verification tools. There are no keys to manage and no installation.

For the easy onboarding path, refer to the client picker on hail.so/mcp. It has copy-paste setup for the 8 most common clients (Claude.ai, ChatGPT, Cursor, Gemini, …). This page is the technical reference behind those snippets.

URL

  • Hail Cloud: https://mcp.hail.so
  • Self-hosted: http://<your-host>:8081 — refer to Self-host below.

The Streamable HTTP transport serves the MCP root path. There is no /mcp suffix and no SSE.

For web-based clients (Claude.ai, ChatGPT), the URL must be reachable from the client's servers — public DNS + TLS. If you want web clients to reach a self-hosted instance, tunnel it via cloudflared / tailscale funnel.

Tools

Example. Ask your agent: "Find me a US number that can call and text." It calls quote_numbers with:

{ "country_code": "US", "capabilities": ["voice", "sms"] }

The server exposes 36 tools. Schemas (args, validation, return shapes) are the source of truth — refer to mcp/hailhq/mcp/tools.py.

Every tool sets the MCP hints readOnlyHint, destructiveHint, idempotentHint and openWorldHint, so clients can ask for approval before a write. release_number, delete_agent, acquire_number, submit_verification and cancel_verification are marked destructive. The first three also need a confirmation argument (confirm_e164, confirm_name, confirm_total_cents). submit_verification needs attest_authorized: true.

place_call, send_sms and send_email need recipient_consent set to the JSON boolean true. Strings such as "true" and numbers such as 1 are refused before any request is sent.

ToolDoes
place_callOriginate an outbound phone call.
get_callFetch the current state of one call.
list_callsList recent calls (cursor-paginated).
send_smsSend an outbound SMS (recipient consent is required).
get_smsFetch the current state of one SMS.
list_smsList recent SMS messages (cursor-paginated).
send_emailSend an outbound email (supports attachment_ids).
upload_email_attachmentUpload a file, get back a reusable id.
get_emailFetch one email's full record (body + inbound headers).
list_emailsList emails (direction="inbound" for replies).
get_email_rawPresigned URL for an inbound email's raw MIME.
get_email_attachmentPresigned URL for one inbound attachment.
get_email_eventsPage through one email's event history.
get_email_statsAggregate email counts for a time window.
get_eventsPage through the event stream.
list_email_domainsList sending identities + the default from address.
whoamiIdentify the human behind the session (for reply_to).
list_contactsList the org's contacts (members + manual contacts).
lookup_contactFind one contact by name, email, or phone fragment.
create_contactAdd a manual contact.
list_agentsList saved agents.
get_agentFetch one saved agent.
create_agentSave an agent that answers calls and texts.
update_agentChange an agent. clear_fields removes optional values.
delete_agentDelete an agent. Destructive. Needs confirm_name.
route_numberChoose which agent answers a number.
quote_numbersCompare live carrier offers for a new number (read-only).
acquire_numberBuy a quoted number. Spends money. Needs confirm_total_cents.
list_numbersList the org's numbers (cursor-paginated).
get_numberFetch one number.
release_numberRelease a number. Destructive and permanent. Needs confirm_e164.
get_verification_requirementsRead what a carrier needs to verify the org for a number type (read-only).
submit_verificationSend details and documents to the carrier. Irreversible. Needs attest_authorized.
list_verificationsList the org's verifications and their states (read-only).
get_verificationFetch one verification; poll it for approved (read-only).
cancel_verificationCancel an awaiting_review one or dismiss a rejected one.

Getting a number over MCP

  1. quote_numbers: {"country_code": "US", "capabilities": ["voice", "sms"]}. Offers list monthly_cents, setup_cents and quote_id. Money is in cents. Quotes expire in 10 minutes.

  2. acquire_number: copy quote_id, country_code and number_type from the offer. Set confirm_total_cents to monthly_cents + setup_cents. For an offer with monthly_cents: 115 and setup_cents: 50:

    {
      "quote_id": "8b1f0c2e-0000-4000-8000-000000000001",
      "country_code": "US",
      "number_type": "local",
      "confirm_total_cents": 165
    }

    The server checks the total. If it differs from the quote, the call fails with 409 and nothing is charged. The result is the number plus an idempotency_key. To retry after a timeout, pass that key as idempotency_key.

  3. route_number: {"number_id": "<id>", "voice_agent_id": "<agent-id>"}.

Warning: acquire_number buys a real number. It charges the setup fee and the first month now, then renews monthly. The account balance is the only spending limit. Keep the human approval prompt on for this tool.

Buying a number that needs verification

Some numbers, for example a UK mobile, need a carrier check of the organization first. quote_numbers marks those offers readiness: "verification_required". The whole flow works over MCP:

  1. quote_numbers: {"country_code": "GB", "number_type": "mobile"}. The offer has readiness: "verification_required".

  2. get_verification_requirements: {"country_code": "GB", "number_type": "mobile"}. The result lists fields, documents (skip a slot whose needs_input is false) and address_required.

  3. Ask the user for every field and file. The agent must not invent or guess any value.

  4. submit_verification. files[].content_base64 is the file's bytes in base64. Slot names in files must be keys in documents:

    {
      "country_code": "GB",
      "number_type": "mobile",
      "fields": { "first_name": "Ada", "last_name": "Lovelace" },
      "address": {
        "customer_name": "Ada Lovelace",
        "street": "1 Example Road",
        "city": "London",
        "region": "London",
        "postal_code": "N1 1AA",
        "country_code": "GB"
      },
      "documents": { "identity": { "option": "passport", "fields": {} } },
      "files": [
        {
          "slot": "identity",
          "content_type": "image/jpeg",
          "content_base64": "<base64 of the passport photo>"
        }
      ],
      "attest_authorized": true
    }

    The result holds the verification id and state, never the details. If the carrier finds a problem, the result lists problems (loc, msg) and nothing is stored. Fix them with the user and submit again.

  5. get_verification: {"verification_id": "<id>"}. Poll every few minutes to hours, not every few seconds. Review takes business days. Hail sends no email or webhook when it ends.

  6. On approved: quote_numbers again. The offer is now ready. Then acquire_number and route_number as above.

  7. On rejected: read rejection_reason, ask the user, call cancel_verification to dismiss it, and submit a new one.

Limits: JPG, PNG or PDF files, 10 MiB each, 29 MiB in total, at most 20 files and 20 fields. Compress photos to under about 7 MiB. Slot names use letters, digits, - and _ (at most 64). Each file must match its type. A second submission for the same country and number type returns 409 while one exists.

Warning: The user's details and documents pass through the model's context and are kept in the client's transcript. Keep the human approval prompt on for submit_verification, and have the user hand over each value and file in the conversation. A submitted verification cannot be cancelled, and the carrier keeps the documents. Only awaiting_review and rejected ones can be cancelled.

Claude.ai (web)

  1. Settings → Connectors → Add custom connector
  2. Server URL: https://mcp.hail.so
  3. Save. Claude prompts you to authorize on first use. Click Allow in the browser tab that opens.

That is all. There is no API key field.

ChatGPT (web)

Custom MCP connectors are behind Developer Mode on ChatGPT today:

  1. Settings → Connectors → Advanced → Developer mode
  2. Create → paste https://mcp.hail.so
  3. Save. ChatGPT guides you through the OAuth consent on the first call.

Other clients

The client picker has setup snippets for Cursor, Gemini, Windsurf, Copilot, Zed, Raycast, and Claude Desktop. All follow the same shape: paste the URL, then click Allow.

Authorized apps

Each cloud client that you connect appears as a row at hail.so/console/apps. Revoke deletes the consent and every active access and refresh token for that client. The client's next tool call returns 401 and runs OAuth again from the start.

Access tokens last 30 days, and refresh tokens last 180 days. In practice, you authorize each client again approximately every six months. The consent screen opens, you click Allow, and the client continues.

Self-host

Self-hosted deployments do not use OAuth. Set HAIL_API_KEY and send it as a bearer token:

curl -H "Authorization: Bearer ${HAIL_API_KEY}" http://localhost:8081/

The MCP service selects its auth mode from env at boot. For the full env-var contract, refer to the MCP modes table in the operations runbook (HAIL_AUTH_URL for cloud, HAIL_API_KEY for self-host, mutually exclusive).

Why remote-only (no stdio / no PyPI install)

We release one MCP distribution: a remote HTTP endpoint. It is bundled with every Hail deploy. We deliberately do not publish a stdio MCP server on PyPI. The reasons:

  1. Web UIs cannot run stdio servers. Claude.ai's MCP Connectors and ChatGPT's Custom Connectors accept only remote URLs. They cannot start local processes from a browser.
  2. Every terminal client also accepts a remote URL. Claude Code, Claude Desktop, Cursor, and Zed all connect to a URL. The URL flow works for all clients. Stdio works only for a subset.
  3. Stdio fragments distribution. Two artifacts (PyPI stdio wrapper + HTTP service) cause two versions to keep in sync, two install paths, and two failure modes.
  4. Installation friction. Stdio requires Python + pip/uv on the user's dev machine. Remote HTTP requires nothing. You only paste a URL.

If a restricted client ever needs stdio, we will release a thin stdio-to-HTTP proxy on PyPI (approximately 50 LOC).

On this page