ThunderPhone 2.0 is live.Self-serve, from 2¢/min.Read the announcement

Developer cookbook

Run calls from Slack or a chat app

Build your own Slack, Teams or web-chat bot that starts calls, follows them live, steers the agent and answers its questions, using the public API.

You can drive an agent from the chat app your team already uses. A message starts a call. The call plays out live in a thread. A reply in the thread steers the agent. When the agent needs a decision, it asks you there and waits for your answer.

ThunderPhone doesn't host this bot for you. You (or your coding agent) build a small server that connects your chat app to the public API. This page shows the pieces. They are the same for Slack, Microsoft Teams, Discord or a private web chat page; the examples use Slack.

How it fits together

You do this in chatYour bot callsWhat happens
Send "Call Comcast and get the outage ETA"POST /v1/call with the message as contextThe agent dials. Your bot opens a thread for the call
Watch the threadReceives telephony.turn webhooksEach line the agent or the other person says appears in the thread
Reply in the thread mid-callPOST /v1/calls/{call_id}/whisperThe agent gets your note privately and acts on it. The other person never hears it
Answer the agent's questionYour tool endpoint returns your answer, or delivers it later with POST /v1/calls/{call_id}/tool-resultsThe agent continues the call with it; in hold mode the other person hears hold music meanwhile
Read the resultReceives telephony.completeYour bot posts the summary and outcome

Your bot keeps one mapping: which chat thread belongs to which call_id.

Before you start

  • An API key, kept on your server and never in a browser or in chat. Create one under Organization Settings → Keys (see Keys).
  • An outbound-enabled number and an agent for the calls. The message becomes per-call context, so one general agent handles every task.
  • A server that can receive webhooks, such as Railway, Fly.io, Render or your own host.
  • A webhook endpoint pointing at that server and subscribed to telephony.turn and telephony.complete. Turn events are only sent to endpoints that list them. See Webhooks. Turn events need the agent's call recording on, and the end-of-call summary needs the agent's Generate a call summary setting; without them those fields stay empty.
  • Your own chat app. For Slack, create an app in your workspace at api.slack.com/apps.

1. Start a call from a message

When your bot receives a message (a direct message or an @mention), acknowledge Slack's request right away (Slack retries anything not acknowledged within 3 seconds) and do the rest in the background. Check that the sender is allowed to place calls, then start the call with the message as context:

curl -X POST https://api.thunderphone.com/v1/call \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from_number": "+15551234567",
    "to_number":   "+18005550100",
    "agent_id":    12,
    "context":     "Matthew asked: get the outage ETA for account 4471. Wait on hold if needed.",
    "idempotency_key": "slack-D012AB3CD-1731000000.000100"
  }'

Save the returned call_id against the Slack thread (channel plus the message ts) and reply in the thread ("Calling…"). Build the idempotency_key from the channel and the message ts (a ts is unique only within its channel), so a retried Slack event doesn't place a second call. If the request names a person rather than a number, your bot looks the number up first. ThunderPhone dials only the to_number you send.

2. Follow the call in the thread

Each telephony.turn webhook carries the call_id, the speaker (role: assistant for the agent, user for the other person), the text and the turn's position. Look up the thread by call_id and post the line.

  • Verify every webhook before acting on it. See Verify webhook signatures.
  • Deduplicate on the envelope's event_id and order by position. Delivery is at least once, and turns can arrive out of order.
  • Batch lines if a busy call would flood the thread; posting every few seconds reads better than one message per line.

When telephony.complete arrives, post how the call ended (its status and end_reason), the summary, and any extracted data, and close the thread out. See Call complete for the fields.

3. Steer the call from the thread

When someone replies in a live call's thread, send the reply as a whisper, unless the agent is waiting on a question (step 4), in which case the reply is the answer:

curl -X POST https://api.thunderphone.com/v1/calls/987654321/whisper \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"text": "Ask whether they can waive the late fee."}'

The agent hears it privately and works it into its next turn; the other person never hears the whisper itself. A whisper is 1 to 1000 characters. A 409 means the call is no longer live: tell the thread the call has ended.

4. Let the agent ask you

Give the agent a tool, for example ask_owner with one question parameter, that calls your server. Create it as an API connection and link it to the agent. Every tool request carries the call in its X-ThunderPhone-Call-ID header.

There are three ways to get the answer back.

Hold the call (up to 10 minutes). Set the tool to hold mode: spec.wait_mode "hold" and spec.timeout up to 600 seconds (without a timeout a hold lasts 20 seconds), through the API (the dashboard editor has neither field yet). Your endpoint posts the question to the call's thread and answers at once with a pending result:

{"status": "pending", "request_id": "q-C012AB3CD-1731000042.000300"}

The other person hears one line from the agent, then hold music. On a phone menu or voicemail the agent uses spoken updates instead, and a full-duplex agent doesn't hold: it gets the pending reply as the result. When the reply arrives in the thread, deliver it with POST /v1/calls/{call_id}/tool-results:

curl -X POST https://api.thunderphone.com/v1/calls/987654321/tool-results \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"request_id": "q-C012AB3CD-1731000042.000300", "result": {"answer": "Yes, book Tuesday at 10."}}'

The music fades out and the agent carries on with your answer. If nobody answers before the timeout, the agent is told no answer came and offers the other person next steps. While a question is pending for a call, the next reply in its thread answers it: deliver it as the tool result and don't also send it as a whisper (a whisper only reaches the agent after the tool returns). See Long waits.

Wait in the request (up to 3 minutes). Your endpoint posts the question, waits for a reply and returns it, such as {"answer": "Yes, book Tuesday at 10."}. Set the tool's spec.timeout through the API, up to 180 seconds (the default is 20). Give your endpoint its own deadline a few seconds shorter (for example 170 seconds), so that when nobody answers it still returns something the agent can act on, such as {"answer": null, "note": "No reply yet. Offer to take a message."}. A tool that hits its timeout reaches the agent as an error, and your host must keep a request open that long. While it waits, the agent tells the other person it's checking, says it's still working after about 12 seconds, and then gives a short update about every 15 seconds. Hold mode works here too: set spec.wait_mode to "hold" and the other person hears music instead of the updates.

Answer by whisper. Your endpoint posts the question and returns at once, for example {"status": "asked", "note": "Matthew has been asked; his answer will come as guidance."}. When the reply arrives, send it as a whisper (step 3), and the agent responds to it right away. Use this for answers that may take longer than 10 minutes. The call isn't held: your prompt should tell the agent what to do meanwhile, such as keeping the person company or offering to call them back.

In every case, say in the agent's prompt when to use the tool and what to tell the other person while it waits:

When you need Matthew's decision, use ask_owner with a short question.
Tell the person you're checking with Matthew and it may take a minute.
If there's no answer, offer to take a message or have Matthew call back.

Limits to plan around

  • Your endpoint must reply within the tool's timeout, at most 180 seconds. A tool in hold mode can keep the call on hold for up to 600 seconds with a pending result.
  • While a tool waits, the agent doesn't answer the other person. In hold mode they hear music; otherwise, short spoken updates.
  • A whisper is at most 1000 characters.
  • telephony.turn delivery is at least once and may be out of order.

Security checklist

  • Keep the API key on your server.
  • Verify ThunderPhone's signatures on webhooks and tool requests, and Slack's signing secret on Slack's requests. The two ThunderPhone signatures use different secrets: webhook deliveries use the webhook endpoint's own secret, and tool requests use your organization's webhook secret from GET /v1/webhook.
  • Decide who may start calls: check the Slack user ID before placing one.
  • Outbound calls follow the same rules as any other. See Outbound calling laws.

Build it with a coding agent

Point your coding agent at the docs MCP server and at this page, and ask for "a Slack bot for my ThunderPhone agent that follows this guide". Zeus, the assistant in the ThunderPhone app, can set up the agent's side (the webhook endpoint, the tool and the prompt lines) and write starter code for the bot in the chat.