> For the complete documentation index, see [llms.txt](/llms.txt).
> A full single-fetch corpus is available at [llms-full.txt](/llms-full.txt).
---
title: Build a Slack deep research bot
description: Mention a bot in Slack, approve its research plan with a button, and get a sourced report in the thread. AGNT5 acknowledges the webhook right away, pauses for approval, and runs three focused agents in the background.
tags: ["Slack", "Agents", "Webhooks", "Human-in-the-loop", "Long-running"]
date: 2026-10-14
last_verified: 2026-10-15
audience: both
---

Mention `@ResearchBot` in any channel. It posts a research plan with **Approve** and **Reject** buttons, and once you approve, a sourced report lands in the thread a minute or two later.

<DocsVideo
  version="4"
  src="/videos/cookbooks/slack-overview"
  mode="player"
  title="A user asks ResearchBot in Slack to compare Raft and Paxos. The bot posts its research plan with Approve and Reject buttons; after the user approves, a sourced comparison appears in the thread."
  caption="From mention to report. The plan, report, and timings come from a real run of this bot; the Slack window is a mockup."
/>

## Why it's built this way

Slack expects an answer to its webhook within **3 seconds** and retries if it doesn't get one. A research run takes about **2 minutes**, and someone should check the plan before the bot spends time and tokens on it. So the bot splits the work:

- **Acknowledge immediately.** AGNT5 records the event and returns `200 OK` within Slack's 3-second limit, so Slack has no reason to retry.
- **Ask before researching.** The workflow posts its plan to the thread and pauses until someone clicks **Approve** or **Reject**.
- **Research in the background.** Three focused agents (scoping, research, writing) run as separate steps instead of one large prompt.

## How it works

<DocsVideo
  version="4"
  src="/videos/cookbooks/slack-architecture"
  title="Architecture: Slack sends a webhook to the AGNT5 gateway, which starts the slack_deep_research workflow. ScopingAgent writes a plan, the workflow posts it to Slack and pauses; the Approve click resumes it, ResearchAgent and WritingAgent run, and chat.postMessage posts the report to the thread."
  caption="The gateway answers Slack right away. The workflow pauses for approval, then runs the research and writing agents and posts the report to the thread."
/>

| Step | What it does |
|---|---|
| `claim_event` | Posts "Planning research…" to the thread, tagged with Slack's `event_id`, so a second delivery of the same event stops here |
| `plan_research` | ScopingAgent splits the question into subtopics, such as each algorithm's leader election |
| `request_plan_approval` | Edits that message into the plan with **Approve** and **Reject** buttons |
| `wait_for_user` | Pauses the run until a button is clicked |
| `conduct_research` | ResearchAgent searches Wikipedia for each subtopic, then reads the most relevant pages: articles, papers, and project docs |
| `write_report` | WritingAgent writes a Slack-formatted report with sources |
| `post_report` | Posts the report to the thread, unless this run's report is already there |

### The workflow

The workflow turns the Slack event into a topic, plans, waits for approval, then researches and replies in the thread:

```python title="workflows.py"
# workflows.py
@workflow(
    name="slack_deep_research",
    triggers=[event("slack.app_mention"), event("slack.message")],
)
async def slack_deep_research(ctx: WorkflowContext, **envelope) -> dict:
    interaction = parse_interaction(envelope)  # an Approve / Reject click
    if interaction:
        return await _handle_approval_click(ctx, interaction, envelope.get("bot_token") or "")

    message = _extract_slack_message(envelope)  # channel, text, thread_ts, event_id
    topic = _extract_topic(message)  # text without the @mention
    # ...
    claim = await ctx.step(
        _claim_event, channel, thread_ts, message["event_id"], bot_token, ctx.run_id
    )
    if claim.get("duplicate"):  # Slack delivered this event twice
        return {"status": "skipped", "reason": "duplicate delivery"}

    plan = await ctx.step(_plan_research, topic)
    await ctx.step(
        _request_plan_approval, channel, claim.get("ts", ""), message["event_id"],
        topic, plan, bot_token, ctx.run_id, os.getenv("AGNT5_DEPLOYMENT_ID", ""),
    )
    decision = await ctx.wait_for_user(
        question=f"Approve this research plan?\n\n{plan}",
        input_type="approval",
        options=[{"id": "approve", "label": "Approve"}, {"id": "reject", "label": "Reject"}],
    )
    if decision != "approve":
        return {"status": "rejected", "topic": topic, "plan": plan}

    findings = await ctx.step(_conduct_research, topic, plan)
    report = await ctx.step(_write_report, topic, plan, findings)
    post_result = await ctx.step(_post_report, channel, thread_ts, report, bot_token, ctx.run_id)
    return {"status": "completed", "topic": topic, "report": report, "slack_post": post_result}
```

Each `ctx.step` is one stage in Studio's run view. When the run resumes after the click, AGNT5 returns the recorded results of the steps that already finished instead of running them again. Posting with `thread_ts` keeps everything in the thread.

### The approval click

Slack sends a button click to the same AGNT5 integration as your events. The workflow reads the paused run's ID from the button and resumes it through AGNT5's [resume API](/docs/build/human-in-the-loop.md):

```python title="approval.py"
# approval.py
def plan_blocks(topic, plan, run_id, deployment_id, event_id=""):
    def button(answer, label, style):
        # The click carries the paused run's ID back to the workflow.
        value = {"run": run_id, "deployment": deployment_id, "event": event_id, "answer": answer}
        return {"type": "button", "text": {"type": "plain_text", "text": label},
                "style": style, "action_id": f"research_{answer}", "value": json.dumps(value)}

    plan_section = {"type": "section", "text": {"type": "mrkdwn", "text": plan}}
    buttons = {"type": "actions", "elements": [button("approve", "Approve", "primary"),
                                               button("reject", "Reject", "danger")]}
    return [plan_section, buttons]


async def resume_run(run_id, deployment_id, answer):
    async with httpx.AsyncClient(timeout=15) as client:
        response = await client.post(
            f"{GATEWAY_URL}/v1/workflows/resume/{run_id}",
            headers={"X-API-KEY": os.getenv("APPROVAL_SERVICE_KEY"),
                     "X-DEPLOYMENT-ID": deployment_id},
            json={"user_response": answer},
        )
    return {"resumed": response.status_code < 400}
```

After resuming, the bot edits the message to "Approved by @you", which removes the buttons. A second click that slips in first finds the run already resumed, and the resume call fails without changing anything.

### The research agent

The research step hands the approved plan to an agent that has two tools and decides what to search and which pages to read. All three agents use `gpt-6-luna`:

```python title="agents.py, functions.py"
# agents.py, functions.py
research_agent = Agent(
    name="ResearchAgent",
    model="openai/gpt-6-luna",
    instructions="""You are a systematic research specialist ...""",
    tools=[wikipedia_search_tool, fetch_webpage_tool],
)

@function(name="conduct_research")
async def _conduct_research(ctx: FunctionContext, topic: str, research_plan: str) -> str:
    prompt = f"Execute systematic research for the following topic: {topic} ..."
    result = await research_agent.run(prompt, context=ctx)
    return result.output
```

## What the bot guarantees

AGNT5 records each step once it completes. If the worker crashes, completed steps are restored without running them again. A step that was interrupted, including an LLM call in progress, may run again.

Posting to Slack is an external side effect, and Slack's `chat.postMessage` has no idempotency key, so the bot makes its own posts safe to repeat:

- **One run per Slack event.** AGNT5 doesn't deduplicate Slack retries, so the first step claims Slack's `event_id` with a tagged "Planning research…" message. Another delivery of the same event finds the tag and stops. Two deliveries that arrive within about a second of each other can still both start.
- **One plan, one report.** The plan edits the claim message in place, and the report is posted only if this run's report isn't already in the thread. A step that runs again after a crash finds its earlier message instead of posting a second one.

Every bot message carries [Slack message metadata](https://api.slack.com/metadata), and the bot reads the thread before it posts:

```python title="slack_once.py"
# slack_once.py
async def post_once(client, token, channel, thread_ts, text, payload, match, blocks=None):
    existing, error = await find_bot_message(client, token, channel, thread_ts, match)
    if existing:  # this message is already in the thread
        return {"posted": False, "existing": True, "ts": existing["ts"]}
    data = await _call(client, "chat.postMessage", token, json={
        "channel": channel, "thread_ts": thread_ts, "text": text, "blocks": blocks,
        "metadata": {"event_type": "research_bot", "event_payload": payload},
    })
    return {"posted": data.get("ok", False), "ts": data.get("ts", "")}
```

## Test locally without Slack

Run the worker on your machine and send it a Slack-shaped event from the CLI:

```bash
agnt5 dev -d
agnt5 run workflow slack_deep_research --input '{
  "body": {
    "event": {
      "type": "app_mention",
      "text": "<@U0BOT> compare Raft and Paxos: leader election, and which systems use each",
      "channel": "C0TEST",
      "ts": "1"
    }
  }
}'
```

The run plans, then reports `status: paused` with its run ID. Approve it the way the button does, through the resume API, with your service key and your dev deployment's ID (it's in `.agnt5/dev-deployment-id`):

```bash
curl -X POST "https://gw.agnt5.com/v1/workflows/resume/<run-id>" \
  -H "Content-Type: application/json" \
  -H "X-API-KEY: $APPROVAL_SERVICE_KEY" \
  -H "X-DEPLOYMENT-ID: $(cat .agnt5/dev-deployment-id)" \
  -d '{"user_response": "approve"}'
```

The run continues, and its output includes the full `report`. Nothing is posted to Slack locally (`slack_post.reason` is `no token or channel`).

## Connect it to Slack

You need an [AGNT5 account](https://app.agnt5.com), Python 3.12+ with [uv](https://docs.astral.sh/uv/getting-started/installation/), an [OpenAI API key](https://platform.openai.com/api-keys) with access to `gpt-6-luna`, and a Slack workspace where you can install apps.

<Steps>
<Step title="Create the project">

```bash
agnt5 create --template python/slack_deep_research my-research-bot
cd my-research-bot && uv sync
```

</Step>
<Step title="Add your keys and deploy">

The OpenAI key runs the agents. The service key lets the workflow resume itself when someone clicks a button.

```bash
agnt5 secrets set --name OPENAI_API_KEY --type api_key
agnt5 service-keys create --name slack-approver --project <project-id> --scopes run,workflow
agnt5 secrets set --name APPROVAL_SERVICE_KEY --type api_key   # paste the service key
agnt5 deploy --env production
```

</Step>
<Step title="Connect Slack">

Follow the [Slack integration guide](/docs/integrations/event-sources/slack.md). For this bot, choose:

- **Bot token scopes:** `app_mentions:read`, `chat:write`, and `channels:history` (add `groups:history` for private channels), so the bot can check the thread before it posts
- **Bot events:** `app_mention` only
- **Interactivity:** turn it on, and set its **Request URL** to the same integration URL as **Event Subscriptions**
- **Target in Studio:** your production deployment and the `slack_deep_research` workflow

</Step>
<Step title="Mention the bot">

Invite the bot to a channel and ask it a question with a few parts, for example `@ResearchBot compare Raft and Paxos: leader election, and which systems use each`. Approve the plan it posts, and the report follows in the thread.

</Step>
</Steps>

## Debug in Studio

Open **Studio → Events** to see each Slack event and where it was dispatched. Open **Studio → Runs** to see the run it started: every step, agent, and tool call, with its input, output, and duration.

<DocsVideo
  version="4"
  src="/videos/cookbooks/slack-studio"
  title="Studio's Events view: the slack.app_mention event shows Started 1 run, and its Dispatch section shows the bound component workflow: slack_deep_research. In Runs, the run's span tree shows plan_research, request_plan_approval, wait_for_user, conduct_research, and write_report, with each agent and tool call and its duration."
/>

| What you see | What it means |
|---|---|
| Slack says the app "is not configured to handle interactive responses" | Turn on **Interactivity** in the Slack app and set its Request URL to your integration URL |
| A run that stays **paused** after a click | Open the click's run (it finishes in about a second) and check its output: `reason` says why the resume failed, such as a missing `APPROVAL_SERVICE_KEY` |
| An event marked **Rejected** | The integration's deployment doesn't serve `slack_deep_research`: point the integration at the deployment you deployed |
| A run that finished in under a second | The workflow skipped the event (the bot's own reply, or a second delivery of the same event: `reason` is `duplicate delivery`), or it handled a button click |
| A post result with `unchecked: missing_scope` | The bot couldn't read the thread, so it posted without checking: add the history scopes from step 3 |
| A completed run but no reply in Slack | Check the run output: `slack_post.reason` says why posting failed |

## Extend it


  - [More human-in-the-loop](/docs/build/human-in-the-loop) — Let reviewers edit the plan instead of only approving it.
  - [Add more tools](/docs/build/tools) — Give ResearchAgent web search alongside Wikipedia.
  - [Promote through environments](/docs/run/environments) — Test in staging with its own Slack token, then promote.

