---
title: "Run calls from Slack or a chat app"
description: "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`](/api-reference/outbound-calls) with the message as `context` | The agent dials. Your bot opens a thread for the call |
| Watch the thread | Receives [`telephony.turn`](/webhooks/events) 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`](/api-reference/calls#whisper-to-the-agent) | The agent gets your note privately and acts on it. The other person never hears it |
| Answer the agent's question | Your [tool endpoint](/guides/build-tool-integration) returns your answer, or delivers it later with [`POST /v1/calls/{call_id}/tool-results`](/api-reference/calls#deliver-a-tool-result) | The agent continues the call with it; in hold mode the other person hears hold music meanwhile |
| Read the result | Receives [`telephony.complete`](/webhooks/events) | 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](/guides/organization-settings#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](/webhooks/endpoints).
  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](https://api.slack.com/apps).

<Tip>
  Use a Slack app you create for your own workspace, and receive messages
  through Slack's Events API rather than reading channel history. Slack
  puts tight read limits on apps distributed to other workspaces outside
  its Marketplace; an app you build for your own workspace isn't held to
  them, and events reach you as they happen without any reads. Typical
  bot scopes: `chat:write`, `app_mentions:read`, `im:history` (direct
  messages), `channels:history` and `groups:history` (thread replies in
  public and private channels the bot is in). Subscribe to the bot events
  `app_mention`, `message.im`, `message.channels` and `message.groups`, and
  turn on the Messages tab under App Home so people can DM the bot.
</Tip>

## 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`:

```bash
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](/guides/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](/webhooks/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:

```bash
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](/guides/build-tool-integration) 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](/api-reference/integrations#timeout-and-wait-mode) (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:

```json
{"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`](/api-reference/calls#deliver-a-tool-result):

```bash
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](/tools/overview#long-waits-hold-mode-and-answer-later).

**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:

```text
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`](/api-reference/organizations#legacy-single-url-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](/guides/outbound-calling-laws).

## Build it with a coding agent

Point your coding agent at the [docs MCP server](/guides/docs-mcp) 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.
