Neural Tech Daily
ai-tutorials

Build a Slack Daily-Standup AI Assistant With Claude: End-to-End Python Tutorial (May 2026)

End-to-end Python tutorial: a Slack app that posts a daily standup prompt, collects replies, then asks Claude to digest blockers and status by member.

~14 min read
Share
Anthropic API Messages documentation page on docs.claude.com showing the endpoint, authentication headers, and the request-body schema this tutorial calls from a scheduled Fly.io Python job

Image: Anthropic API Messages documentation, used for editorial coverage of the Claude API surface this tutorial calls.

What you’ll build

A Slack assistant that posts a standup prompt to a #standup channel every morning, waits roughly thirty minutes for team members to reply in-thread, then asks Claude to read every reply and post back a single digest message: blockers grouped at the top, status by member underneath. The whole thing runs as a scheduled Fly.io machine, so no laptop has to stay awake and no webhook server is needed.

By the end you will have:

  • A Slack app registered with the minimum OAuth scopes to post messages and read thread replies, per the Slack API OAuth scopes reference and bot-users documentation.
  • A Bolt for Python script that calls chat.postMessage to open the standup thread, then conversations.replies to read the answers.
  • An Anthropic Messages API call that takes the raw thread transcript and returns a structured digest grouped by member.
  • A Fly.io deployment running the script on a scheduled machine that fires every weekday morning, with the Slack bot token and Anthropic API key stored as Fly secrets.

Skills assumed: Python 3.11 or later, comfort with pip and venv, basic familiarity with environment variables and Docker. No prior Slack-app or Fly.io experience is required; every step below is reproducible end to end.

Status check before you start (May 2026)

Three things worth verifying in your own browser before committing an afternoon to this tutorial.

First, Bolt for Python remains the Slack-maintained framework for Python Slack apps. The framework’s getting-started documentation at tools.slack.dev/bolt-python lists the current install command and the supported Python version floor 1 . Re-check that page before installing, since the package floor moves periodically.

Second, Fly.io exposes several cron surfaces. Per Fly’s task-scheduling guide, the lightest option is the scheduled-machines feature: Machines support hourly, daily, weekly, and monthly cadence buckets and exit when their entrypoint exits, so cost stays proportional to actual run time 2 . For finer-grained timing the same guide points to Cron Manager and Supercronic; this tutorial uses the simple scheduled-machine path. Confirm the schedule values your account supports before wiring the deploy.

Third, the Anthropic Messages API is the digest surface. Per the Messages API reference at docs.claude.com/en/api/messages, you POST a JSON body containing the model name, max_tokens, and a messages array, with the model name drawn from Anthropic’s current model catalogue 3 . Frontier-LLM model names drift on roughly 60-day cadences; do not assume the example identifier in the code below is still current on the day you build. Open the model catalogue 4 in your browser and substitute the entry that fits your cost and latency budget.

Step 1: register the Slack app and grab credentials

Go to api.slack.com/apps and click “Create New App”, then “From scratch”. Name the app something the team will recognise in the Slack sidebar, for example “Standup Bot”. Pick the workspace you want to install it in.

Under “OAuth & Permissions”, add the following bot-token scopes (the Slack OAuth scopes reference catalogues every scope name and the capability it grants) 5 :

  • chat:write: post the standup prompt and the digest message.
  • channels:history: read replies in public channels.
  • channels:read: resolve channel names to IDs at runtime.

Click “Install to Workspace” at the top of the OAuth & Permissions page. Slack will show you a “Bot User OAuth Token” starting with xoxb-. Copy it; this is the token your script authenticates with on every API call, per the Slack bot-users documentation 6 .

Invite the bot to the channel you plan to use. In Slack, open the channel and type /invite @standup-bot. Without the invite, chat.postMessage returns not_in_channel.

Step 2: set up a local Python project

Create a project directory and a virtual environment:

mkdir standup-bot && cd standup-bot
python3 -m venv .venv
source .venv/bin/activate

Install Bolt for Python, the Anthropic SDK, and a small dotenv helper for local runs:

pip install "slack-bolt>=1.18" "anthropic>=0.34" "python-dotenv>=1.0"

Create a .env file with the secrets you collected in Step 1 and an Anthropic key from console.anthropic.com. The Anthropic getting-started page walks through generating the key 7 :

SLACK_BOT_TOKEN=xoxb-your-token-here
ANTHROPIC_API_KEY=sk-ant-your-key-here
STANDUP_CHANNEL=standup

The script will resolve STANDUP_CHANNEL to a channel ID at runtime, so you can set it to the human-readable channel name.

Step 3: post the standup prompt and capture the thread

Create bot.py with the message-posting logic. The script uses slack_sdk.WebClient directly rather than the full Bolt app, because there is no incoming webhook to handle; the script is the initiator, not a listener.

import os
import time

from dotenv import load_dotenv
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError

load_dotenv()

SLACK_TOKEN = os.environ["SLACK_BOT_TOKEN"]
CHANNEL_NAME = os.environ.get("STANDUP_CHANNEL", "standup")

client = WebClient(token=SLACK_TOKEN)


def resolve_channel_id(name: str) -> str:
    cursor = None
    while True:
        resp = client.conversations_list(
            limit=200,
            cursor=cursor,
            exclude_archived=True,
            types="public_channel",
        )
        for ch in resp["channels"]:
            if ch["name"] == name:
                return ch["id"]
        cursor = resp.get("response_metadata", {}).get("next_cursor")
        if not cursor:
            break
    raise RuntimeError(f"channel #{name} not found or bot not invited")


def post_prompt(channel_id: str) -> str:
    text = (
        ":sunrise: *Daily standup* — please reply in this thread "
        "with three lines:\n"
        "1. What you shipped yesterday\n"
        "2. What you're shipping today\n"
        "3. Any blockers\n"
        "I'll digest replies in 30 minutes."
    )
    resp = client.chat_postMessage(channel=channel_id, text=text)
    return resp["ts"]

Per the Slack chat.postMessage documentation, the response carries a ts (timestamp) value that uniquely identifies the message inside the channel; you use that ts as the thread_ts parameter when reading replies later 8 .

Step 4: wait, then collect thread replies

Add the wait-and-collect logic to bot.py:

WAIT_SECONDS = int(os.environ.get("STANDUP_WAIT_SECONDS", "1800"))


def collect_replies(channel_id: str, thread_ts: str) -> list[dict]:
    time.sleep(WAIT_SECONDS)
    replies: list[dict] = []
    cursor = None
    while True:
        resp = client.conversations_replies(
            channel=channel_id,
            ts=thread_ts,
            cursor=cursor,
            limit=200,
        )
        for msg in resp["messages"]:
            if msg.get("ts") == thread_ts:
                continue  # skip the prompt itself
            if msg.get("subtype") == "bot_message":
                continue
            replies.append(
                {
                    "user": msg.get("user", "unknown"),
                    "text": msg.get("text", ""),
                    "ts": msg.get("ts"),
                }
            )
        if not resp.get("has_more"):
            break
        cursor = resp.get("response_metadata", {}).get("next_cursor")
    return replies

The Slack conversations.replies method returns every message in the thread, including the parent message; per the method documentation, you pass the parent ts to scope the call to a single thread 9 . The loop above skips the parent and any other bot-authored messages so Claude only sees human replies.

A 30-minute window is the default; tune STANDUP_WAIT_SECONDS per your team’s morning rhythm. A scheduled Fly.io machine is billed for the full sleep duration since the process stays resident, so longer windows cost slightly more (see Step 7 for the trade-off).

Step 5: resolve user IDs to display names

The raw thread carries Slack user IDs like U07ABC123, which Claude has no context for. Resolve them to display names before passing the transcript on:

def resolve_users(user_ids: list[str]) -> dict[str, str]:
    names: dict[str, str] = {}
    for uid in set(user_ids):
        try:
            info = client.users_info(user=uid)
            profile = info["user"].get("profile", {})
            names[uid] = (
                profile.get("display_name")
                or profile.get("real_name")
                or uid
            )
        except SlackApiError:
            names[uid] = uid
    return names

Wrap the call in a try-except: a deactivated user returns user_not_found, which should not block the digest. Default to the raw ID in that case.

Step 6: ask Claude for the digest

This is where the assistant earns its name. Pass the cleaned transcript and a system prompt that pins the output shape:

from anthropic import Anthropic

anthropic = Anthropic()  # reads ANTHROPIC_API_KEY from env

SYSTEM_PROMPT = """You are a standup-digest assistant for a software team.
You will be given a list of thread replies from a daily standup channel.
Each reply is tagged with the author's display name.

Produce a digest in Slack-flavoured Markdown with two sections:

*Blockers*
- One bullet per blocker, each prefixed with the author's name in bold.
  If no blockers are reported, write "None reported."

*Status by member*
- One bullet per author, in the order they replied.
  Format: "*Name* — yesterday: <summary>. today: <summary>."
  Keep each line under 200 characters. Do not invent details
  not present in the reply.

If a reply is unparseable or empty, write "*Name* — no parseable update."
Do not add a preamble. Do not address anyone. Return only the digest.
"""


def build_transcript(replies: list[dict], names: dict[str, str]) -> str:
    lines = []
    for r in replies:
        author = names.get(r["user"], r["user"])
        lines.append(f"[{author}]\n{r['text']}\n")
    return "\n".join(lines) or "(no replies)"


def generate_digest(transcript: str) -> str:
    resp = anthropic.messages.create(
        model="claude-sonnet-4-5",  # verify current model name
        max_tokens=1024,
        system=SYSTEM_PROMPT,
        messages=[{"role": "user", "content": transcript}],
    )
    return resp.content[0].text.strip()

Two points worth flagging here. First, the system prompt locks the output shape. Asking for two named sections with explicit formatting means a small model can hold the structure even on noisy input; per the Anthropic Messages API documentation, output discipline is the prompt-engineering surface, not an API feature 10 . Second, the model identifier claude-sonnet-4-5 in the snippet above is illustrative. Re-check Anthropic’s model catalogue page before running and substitute the current identifier, since model names retire on the cadence noted in Step 2.

Anthropic API Messages reference page on docs.claude.com showing the messages endpoint request schema, model parameter, and system-prompt field this tutorial calls

Image: Anthropic API Messages reference, used for editorial coverage of the Claude API surface this tutorial calls.

Finally, post the digest back into the same thread so the conversation stays in one place:

def post_digest(channel_id: str, thread_ts: str, digest: str) -> None:
    client.chat_postMessage(
        channel=channel_id,
        thread_ts=thread_ts,
        text=digest,
    )


def main() -> None:
    channel_id = resolve_channel_id(CHANNEL_NAME)
    thread_ts = post_prompt(channel_id)
    replies = collect_replies(channel_id, thread_ts)
    if not replies:
        client.chat_postMessage(
            channel=channel_id,
            thread_ts=thread_ts,
            text="_No replies in the standup window. Skipping digest._",
        )
        return
    names = resolve_users([r["user"] for r in replies])
    transcript = build_transcript(replies, names)
    digest = generate_digest(transcript)
    post_digest(channel_id, thread_ts, digest)


if __name__ == "__main__":
    main()

Test locally with a short wait and a private channel before going to production:

STANDUP_WAIT_SECONDS=60 STANDUP_CHANNEL=standup-test python bot.py

Open the channel in Slack, leave a couple of replies during the 60-second window, and confirm the digest renders the way you want.

Step 7: deploy to Fly.io on a daily cron

Fly.io’s scheduled-machines feature runs an image on a fixed cadence and shuts down between runs, which is the right billing shape for a once-a-day script. Per Fly’s launch documentation, fly launch scaffolds the fly.toml and a default Dockerfile for a Python project 11 . The deeper task-scheduling guide 2 covers the heavier Cron Manager and Supercronic options if your scheduling needs grow beyond the once-a-day bucket.

Fly.io launch documentation page on fly.io showing the fly launch command used to scaffold a fly.toml and default Dockerfile for the Python project in this tutorial

Image: Fly.io launch documentation, used for editorial coverage of the deploy-scaffolding step.

Fly.io task-scheduling documentation page on fly.io showing Cron Manager, Supercronic, and scheduled machines as the three Fly cron surfaces, with the scheduled-machines bucket cadence values used by this tutorial

Image: Fly.io task-scheduling guide, used for editorial coverage of the cron surface used in this step.

Write a minimal Dockerfile at the project root, since the scheduled machine runs a one-shot Python entrypoint:

FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY bot.py .
CMD ["python", "bot.py"]

Generate a pinned requirements.txt from the venv:

pip freeze > requirements.txt

Per Fly’s Dockerfile documentation, the platform builds the image on Fly’s remote builder when you run fly deploy, so no local Docker daemon is required 12 .

Initialise the app:

fly launch --no-deploy --copy-config

Accept the generated fly.toml, decline the Postgres / Redis / Tigris prompts. Open fly.toml and remove the [http_service] block, since this app is a scheduled one-shot rather than a long-running web service.

Set the secrets. Per Fly’s secrets documentation, values set via fly secrets set are injected as environment variables inside the running machine 13 :

fly secrets set SLACK_BOT_TOKEN=xoxb-...
fly secrets set ANTHROPIC_API_KEY=sk-ant-...
fly secrets set STANDUP_CHANNEL=standup
fly secrets set STANDUP_WAIT_SECONDS=1800

Build the image once so a deployable artefact exists on Fly:

fly deploy --build-only --push

Then create the scheduled machine. The --schedule flag accepts the cadence values listed in Fly’s task-scheduling guide; for a weekday standup, the most common pattern is daily plus an --env override of the local timezone:

fly machine run . \
  --schedule daily \
  --region <your-region> \
  --env TZ=Etc/UTC

Replace <your-region> with a Fly region close to your team. The first run will fire on the next daily tick; verify with fly machine list that the machine has schedule = daily set. If your team needs weekday-only execution, set the schedule to daily and add a weekday gate inside main() (Python’s datetime.now().weekday() returns 0-4 for Monday-Friday, so guard the body with if datetime.now().weekday() >= 5: return).

Fly.io secrets management documentation page on fly.io showing the fly secrets set command used to inject SLACK_BOT_TOKEN and ANTHROPIC_API_KEY into the scheduled machine

Image: Fly.io secrets management documentation, used for editorial coverage of the secrets-handling step in the deploy.

Step 8: what to watch in production

Two observability surfaces matter once the bot is live.

  • Fly machine logs. fly logs streams stdout from every recent machine run; the once-a-day cadence means logs stay readable. Any SlackApiError or Anthropic SDK error will surface here.
  • Anthropic console usage. Token spend per run depends on how chatty the team is; a five-person standup with two-sentence replies costs a fraction of a cent per digest. Per Anthropic’s Messages API reference, input and output tokens are billed separately, so the system prompt counts on every call 14 .

Failure modes worth designing for ahead of time:

  • The bot gets removed from the channel. chat.postMessage will return not_in_channel. A small alert hook (a Slack DM to an admin user, or a Sentry capture) on SlackApiError makes this visible quickly.
  • Anthropic rate limits or transient 5xx errors. Wrap generate_digest in a retry with exponential backoff; the Anthropic SDK ships with built-in retry support, but a custom layer lets you fall back to posting the raw transcript if the model is unreachable.
  • A user pastes a 10-page incident write-up into the thread. The Messages API has a per-call input-token ceiling per the model catalogue; truncate any single reply above ~2,000 characters in build_transcript to keep the digest call bounded.

Where to go from here

The script above is a foundation, not a finished product. Three natural extensions:

  • Threaded follow-ups. Have the bot DM each member whose reply was empty or marked “no parseable update”, asking for a one-line clarification.
  • Weekly rollup. Add a second scheduled machine on a weekly cadence that reads the last five daily digests via conversations.history and asks Claude for a recurring-blocker summary.
  • Multi-channel support. Generalise STANDUP_CHANNEL to a comma-separated list and run the script per channel sequentially, so a single Fly machine covers several teams in one daily tick.

The Slack and Anthropic APIs both expose enough surface to keep extending this assistant without rewriting the core flow. Once the daily digest reads like something the team would have written by hand, the next pieces are mostly prompt iteration and small Slack-API additions.

How this article was made: an autonomous AI pipeline researched, drafted, fact-checked, and reviewed this piece, aggregating publicly-available information from the sources consulted below. AI (artificial intelligence) can make mistakes, so please cross-check the consulted sources before acting on anything here. Neural Tech Daily is not liable for decisions or outcomes based on this article.

Sources consulted

Anonymous · no cookies set

Report a problem with this article

Articles are produced by an autonomous AI pipeline; mistakes do happen. Tell us what's wrong and the editorial review will revisit the claim.

Category

Found this useful? Share it.