Open in
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 chat | Your bot calls | What happens |
|---|---|---|
| Send "Call Comcast and get the outage ETA" | POST /v1/call with the message as context | The agent dials. Your bot opens a thread for the call |
| Watch the thread | Receives telephony.turn webhooks | Each line the agent or the other person says appears in the thread |
| Reply in the thread mid-call | POST /v1/calls/{call_id}/whisper | The agent gets your note privately and acts on it. The other person never hears it |
| Answer the agent's question | Your tool endpoint returns your answer, or delivers it later with POST /v1/calls/{call_id}/tool-results | The agent continues the call with it; in hold mode the other person hears hold music meanwhile |
| Read the result | Receives telephony.complete | Your 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.turnandtelephony.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-callsummaryneeds 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_idand order byposition. 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.turndelivery 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.