Building a Slack Standup Digest Bot on Top of conversations.history

A team gathered in an office for a morning briefing, representing a daily standup update

A Slack standup digest bot sounds like a weekend project: pull yesterday’s messages from a channel with conversations.history, hand them to an LLM, post a per-person summary back each morning. The part that actually breaks in production isn’t the summarization — it’s the Slack API call itself, because the rules around how often you’re allowed to call conversations.history changed in 2025 for exactly the kind of app you’re about to build.

What the bot needs from Slack before it can read anything

conversations.history takes a required channel parameter and an optional cursor, oldest, latest, inclusive, and limit (up to 999, though Slack’s own documentation recommends no more than 200 per call). None of that works until the bot token carries the right scopes — and the scopes differ by conversation type, which is the first thing that trips people up when a bot reads public channels fine in testing and then 403s the moment someone tries it in a private one:

Conversation typeScope conversations.history needs
Public channelchannels:history
Private channelgroups:history
Direct messageim:history
Multi-person DMmpim:history

A standup bot reading one public team channel only needs channels:history plus channels:read to resolve the channel ID. Add the others only if the bot also needs to read private standup channels or DMs — requesting scopes you don’t use is both an unnecessary approval step for whoever installs the app and a larger blast radius if the token ever leaks.

Step 1: Resolve the channel and set your time window

The bot needs a Unix timestamp for “yesterday at 9am” (or whatever your standup cutoff is) to pass as oldest, and “today at 9am” as latest. Slack timestamps are strings like "1733875200.000000", not integers, so build them carefully:

from slack_sdk import WebClient
from datetime import datetime, timedelta, timezone

client = WebClient(token=os.environ["SLACK_BOT_TOKEN"])

now = datetime.now(timezone.utc)
cutoff_today = now.replace(hour=9, minute=0, second=0, microsecond=0)
cutoff_yesterday = cutoff_today - timedelta(days=1)

oldest = f"{cutoff_yesterday.timestamp():.6f}"
latest = f"{cutoff_today.timestamp():.6f}"

channel_id = client.conversations_list(
    types="public_channel", limit=200
).data["channels"]
channel_id = next(c["id"] for c in channel_id if c["name"] == "standup")

Resolve the channel ID once and cache it — conversations.list is a separate rate-limited call, and looking the name up on every run burns budget you’ll want for the history pull itself.

Step 2: Pull the window, and paginate if it’s a busy channel

A single call returns up to limit messages (200 is the practical ceiling) between oldest and latest, newest first, and sets has_more: true if there’s another page. The response carries the next page’s cursor in response_metadata.next_cursor — pass that back in, don’t try to compute it yourself:

messages = []
cursor = None
while True:
    resp = client.conversations_history(
        channel=channel_id,
        oldest=oldest,
        latest=latest,
        limit=200,
        cursor=cursor,
    )
    messages.extend(resp["messages"])
    cursor = resp.get("response_metadata", {}).get("next_cursor")
    if not cursor:
        break

Two things in that response payload matter more than they look: messages posted inside a thread do not appear here unless they’re also the thread’s parent message, so a standup where people reply in-thread to a daily prompt needs a separate conversations.replies call per thread; and bot_id will be set on any message your own bot or another integration posted, which you’ll want to filter out before summarizing, or the digest ends up summarizing itself.

The rate-limit wrinkle that changes how you build this

Here’s the part that doesn’t show up in a quick-start tutorial: Slack’s documentation lists conversations.history at Tier 3, which is 50 or more requests per minute — comfortable for a pagination loop. But for newly created apps that are commercially distributed and not approved for the Slack Marketplace, Slack tightened this specific method starting May 29, 2025: those apps get 1 request per minute to conversations.history (and to conversations.replies), and the maximum limit per call drops from 200-ish down to 15 messages. Existing installations made before that date keep their old limits.

App categoryRequests/minute to conversations.historyMax limit per call
Marketplace-approved or internal/custom appTier 3 — 50+Up to 999 (Slack recommends ≤200)
New commercially distributed app, not Marketplace-approved (created after May 29, 2025)115
Existing non-Marketplace app installed before that dateUnaffected by the new limitUnaffected

If you’re building this as an internal tool for your own workspace, none of this touches you — internal apps keep Tier 3. It only bites if you’re packaging the bot as something other teams install from outside the Marketplace, where a pagination loop written against the old assumptions now takes minutes instead of seconds to pull one day of a moderately busy channel, and a naive retry-on-429 loop can make that worse, not better.

Before and after: a pagination loop that survives the new limit

Before — written against Tier 3 assumptions, this pulls the whole window in a tight loop with no pacing. Under the new limit it immediately exhausts the one-request-per-minute budget and spends the rest of the run retrying:

messages = []
cursor = None
while True:
    resp = client.conversations_history(
        channel=channel_id, oldest=oldest, latest=latest,
        limit=200, cursor=cursor,
    )
    messages.extend(resp["messages"])
    cursor = resp.get("response_metadata", {}).get("next_cursor")
    if not cursor:
        break

After — paces each call to the budget that actually applies, shrinks the page size, and persists the cursor between runs so a slow pull resumes instead of restarting from oldest every time the script runs:

import time, json, pathlib

STATE_FILE = pathlib.Path("standup_cursor.json")
state = json.loads(STATE_FILE.read_text()) if STATE_FILE.exists() else {}
cursor = state.get("cursor")
messages = state.get("messages", [])

PAGE_LIMIT = 15        # new per-call ceiling
SECONDS_BETWEEN_CALLS = 61   # 1 req/minute budget, with margin

while True:
    resp = client.conversations_history(
        channel=channel_id, oldest=oldest, latest=latest,
        limit=PAGE_LIMIT, cursor=cursor,
    )
    messages.extend(resp["messages"])
    cursor = resp.get("response_metadata", {}).get("next_cursor")
    STATE_FILE.write_text(json.dumps({"cursor": cursor, "messages": messages}))
    if not cursor:
        break
    time.sleep(SECONDS_BETWEEN_CALLS)

Whether you need the second version depends entirely on which row of the table above your app falls into — check that before you build the pacing logic, not after a run times out.

Step 3: Turning raw messages into a per-person digest

Group the filtered, non-bot messages by user, then send each person’s batch to Claude with a prompt that asks for a single short paragraph rather than a bullet-by-bullet restatement — a digest that just re-lists every message isn’t actually saving anyone reading time:

import anthropic
from collections import defaultdict

by_user = defaultdict(list)
for m in messages:
    if m.get("bot_id") or m.get("subtype"):
        continue
    by_user[m["user"]].append(m["text"])

ai_client = anthropic.Anthropic()
digest_lines = []
for user_id, texts in by_user.items():
    resp = ai_client.messages.create(
        model="claude-opus-5-5",
        max_tokens=150,
        messages=[{
            "role": "user",
            "content": (
                "Summarize this person's standup updates in one short "
                "paragraph. Focus on what shipped, what's blocked, and "
                "what's next. Do not restate every line.\n\n"
                + "\n".join(texts)
            ),
        }],
    )
    digest_lines.append(f"<@{user_id}>: {resp.content[0].text}")

Resolve user_id to a display name with users.info if you want readable names instead of raw IDs — Slack renders the <@U012ABC> mention format automatically either way, so leaving it as a mention is usually the simpler choice and it still pings the right person.

Step 4: Post the digest back

client.chat_postMessage(
    channel=digest_channel_id,
    text="*Yesterday's standup digest: *\n\n" + "\n\n".join(digest_lines),
)

Post to a separate digest channel rather than back into the standup channel itself — otherwise the bot’s own summary becomes part of tomorrow’s history pull, and you’re back to the self-summarizing loop mentioned above.

Failure modes worth checking for before you ship it

  • Thread replies go missing from the digest. conversations.history only returns a thread’s parent message, not its replies — if your team answers the standup prompt in-thread, you need a conversations.replies call per thread root, which is its own rate-limited method.
  • The bot summarizes itself. Filter out messages where bot_id is set, including the digest bot’s own prior posts if it shares a channel with the source data.
  • A scope that worked yesterday 403s today. Adding a private standup channel later requires re-requesting groups:history and having the workspace admin reinstall the app — the token doesn’t pick up new scopes on its own.
  • The cursor loop never terminates. Always break when response_metadata.next_cursor is empty or absent, not when messages comes back empty — a busy channel can return an empty page with more pages still behind it.

FAQ

Does the 1-request-per-minute limit apply to internal tools built for one workspace? No — Slack’s documentation specifies the tightened limit applies to newly created apps that are commercially distributed and not approved for the Slack Marketplace. An app built and installed only in your own workspace keeps the standard Tier 3 allowance.

Can I just raise limit back to 200 and ignore the new ceiling? No — for an app that falls under the new restriction, Slack caps the limit parameter itself at 15 for this method; requesting more doesn’t get you more, it just adds an avoidable round trip if you’re not aware of the cap.

This pagination pattern isn’t unique to Slack — the same cursor-plus-time-window shape shows up in Gmail’s History API pagination for an email digest, where the equivalent trap is treating maxResults as a guarantee rather than a ceiling. If you’re scheduling this standup bot to run unattended every morning, pair it with a cron-based pattern for recurring Claude API calls, and if the goal is replacing a synchronous meeting rather than just digesting one channel, the broader workflow questions are covered separately in running an async standup across time zones.

Source: Slack’s conversations.history API reference and Slack’s Web API rate limits documentation.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top