Build a Slack Digest Bot That Summarizes Every Channel's Key Discussions Each Morning
What We're Building
We're shipping a serverless bot that wakes up at 8 AM, rips through yesterday's messages in your team's Slack channels, runs them through a high-speed LLM, and drops a clean digest of key themes, decisions, and blockers into a dedicated #daily-digest channel. No more scrolling through hundreds of threads to catch up. No paid inference endpoints. No persistent servers.
Feature list:
- Fetches messages from configurable Slack channels (public or private with proper scoping).
- Batches threads and top-level messages into structured context windows.
- Summarizes using Groq's free-tier Llama 3 8B (blazing fast, no credit card needed for initial rate limits).
- Extracts action items, key decisions, and open questions explicitly.
- Posts a formatted digest with channel-level breakdowns and direct links to threads.
- Runs on Cloudflare Workers free tier (100k requests/day, 10ms CPU time per invocation—we'll stay well under).
- Zero infrastructure to maintain.
Architecture Overview
Before we touch code, here's the data flow. A cron trigger fires a Cloudflare Worker. The Worker authenticates against Slack's API, pulls history for each channel, concatenates messages into a prompt, sends it to Groq, parses the response, and posts the digest back to Slack.
Prerequisites & Free-Tier Setup
Everything here runs on free tiers. Grab these before you start:
- Cloudflare Workers – Sign up at dash.cloudflare.com. Free plan includes 100k requests/day and 10ms CPU per invocation. Install Wrangler CLI:
npm install -g wrangler. - Groq API Key – Head to console.groq.com and create an API key. Free tier gives you ~30 requests/minute and access to Llama 3 8B, Mixtral, and Gemma models. No billing required.
- Slack App – Go to api.slack.com/apps and create a new app from scratch. You'll need a Bot Token with scopes:
channels:history,channels:read,chat:write,users:read. Install to your workspace.
Step 1: Scaffold the Cloudflare Worker
Create a new Worker project:
npm create cloudflare@latest slack-digest-bot -- --type hello-world
cd slack-digest-bot
Replace the generated src/index.ts with this skeleton:
export interface Env {
SLACK_BOT_TOKEN: string;
GROQ_API_KEY: string;
CHANNEL_IDS: string; // comma-separated
DIGEST_CHANNEL_ID: string;
}
export default {
async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) {
ctx.waitUntil(generateAndPostDigest(env));
},
async fetch(request: Request, env: Env) {
// Manual trigger for testing
await generateAndPostDigest(env);
return new Response("Digest triggered manually.", { status: 200 });
},
};
Set your secrets with Wrangler:
wrangler secret put SLACK_BOT_TOKEN
wrangler secret put GROQ_API_KEY
For CHANNEL_IDS and DIGEST_CHANNEL_ID, use Wrangler's environment variables in wrangler.toml (non-sensitive):
[vars]
CHANNEL_IDS = "C12345,C67890"
DIGEST_CHANNEL_ID = "C11111"
Step 2: Slack App Configuration & Token Scopes
In your Slack App dashboard under OAuth & Permissions, add these Bot Token scopes:
channels:history– read messages in public channels.channels:read– list public channels.chat:write– post digest messages.users:read– resolve user IDs to display names.
If you need private channels, add groups:history and groups:read. Install the app to your workspace and copy the Bot User OAuth Token. Invite the bot to every channel you want it to digest (/invite @YourBotName).
Step 3: Fetching Channel History
Slack's conversations.history endpoint returns messages in reverse chronological order. We'll fetch messages from the past 24 hours. Here's the core fetch function:
async function fetchYesterdayMessages(
channelId: string,
token: string
): Promise<Array<{ user: string; text: string; ts: string; thread_ts?: string }>> {
const yesterday = Math.floor(Date.now() / 1000) - 86400;
let allMessages: any[] = [];
let cursor: string | undefined;
do {
const params = new URLSearchParams({
channel: channelId,
oldest: yesterday.toString(),
limit: "200",
});
if (cursor) params.append("cursor", cursor);
const resp = await fetch(`https://slack.com/api/conversations.history?${params}`, {
headers: { Authorization: `Bearer ${token}` },
});
const data = await resp.json();
if (!data.ok) throw new Error(`Slack API error: ${data.error}`);
allMessages = allMessages.concat(data.messages || []);
cursor = data.response_metadata?.next_cursor;
} while (cursor);
// Resolve user IDs to names
const userIds = [...new Set(allMessages.map(m => m.user).filter(Boolean))];
const userMap = await resolveUserNames(userIds, token);
return allMessages.map(m => ({
user: userMap[m.user] || m.user,
text: m.text,
ts: m.ts,
thread_ts: m.thread_ts,
}));
}
resolveUserNames batches users.info calls efficiently. We'll keep it simple with individual lookups since free tier handles it:
async function resolveUserNames(userIds: string[], token: string): Promise<Record<string, string>> {
const map: Record<string, string> = {};
await Promise.all(userIds.map(async (id) => {
const resp = await fetch(`https://slack.com/api/users.info?user=${id}`, {
headers: { Authorization: `Bearer ${token}` },
});
const data = await resp.json();
map[id] = data.ok ? data.user.real_name || data.user.name : id;
}));
return map;
}
Step 4: Prompt Engineering the Summarizer with Groq
This is where we turn raw messages into a crisp digest. The prompt must constrain the LLM to extract structured insights. We'll use Groq's OpenAI-compatible endpoint with Llama 3 8B.
async function summarizeChannel(channelName: string, messages: Array<{ user: string; text: string }>): Promise<string> {
const conversationText = messages
.map(m => `[${m.user}]: ${m.text}`)
.join("\n");
const prompt = `You are a technical team assistant. Analyze the following Slack messages from #${channelName} from the last 24 hours.
Extract and return a concise digest with these sections:
1. **Key Themes Discussed** (2-4 bullet points)
2. **Decisions Made** (list specific decisions and who made them)
3. **Action Items** (who needs to do what, if mentioned)
4. **Open Questions / Blockers** (unresolved issues)
If a section has no content, write "None."
Messages:
${conversationText.slice(0, 8000)}`; // Truncate to stay within token limits
const resp = await fetch("https://api.groq.com/openai/v1/chat/completions", {
method: "POST",
headers: {
"Authorization": `Bearer ${GROQ_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "llama3-8b-8192",
messages: [{ role: "user", content: prompt }],
temperature: 0.2,
max_tokens: 1024,
}),
});
const data = await resp.json();
return data.choices[0].message.content;
}
Llama 3 8B handles this beautifully, and Groq's inference is sub-second for prompts of this size. The truncation at 8000 characters keeps us well within the model's 8192 token context window.
Step 5: Formatting and Posting the Digest
Now we compose the final Slack message. We'll use Slack's Block Kit for a clean, collapsible layout per channel:
async function postDigest(env: Env, channelDigests: Array<{ channel: string; summary: string }>) {
const dateStr = new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' });
const blocks: any[] = [
{
type: "header",
text: { type: "plain_text", text: `📋 Daily Digest – ${dateStr}` },
},
{ type: "divider" },
];
for (const { channel, summary } of channelDigests) {
blocks.push({
type: "section",
text: { type: "mrkdwn", text: `*#${channel}*\n${summary}` },
});
blocks.push({ type: "divider" });
}
await fetch("https://slack.com/api/chat.postMessage", {
method: "POST",
headers: {
"Authorization": `Bearer ${env.SLACK_BOT_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
channel: env.DIGEST_CHANNEL_ID,
blocks,
text: `Daily Digest – ${dateStr}`,
}),
});
}
Step 6: Scheduling the Cron Job
Cloudflare Workers support cron triggers via wrangler.toml. Add this to the top-level config:
[triggers]
crons = ["0 8 * * 1-5"] # 8 AM UTC, Monday-Friday
Deploy with:
wrangler deploy
Test manually by hitting the Worker's URL directly (the fetch handler we left in triggers a manual run).
Running It Live
- Deploy with
wrangler deploy. - Verify cron is active in the Cloudflare Dashboard under Workers > Triggers.
- Manually trigger once by visiting
https://your-worker.workers.devto see the digest appear. - Check Cloudflare's
wrangler taillogs if anything fails silently.
Cost: $0. You're within free tiers across all three services.
Sensible Extensions
- Thread-aware summarization: Group messages by
thread_tsand summarize each thread separately before the channel-level summary. This surfaces deep-dive discussions that top-level scanning misses. - Sentiment and urgency flags: Add a prompt instruction to tag items with 🔴 blocker, 🟡 needs attention, 🟢 resolved. Makes scanning faster.
- Multi-workspace support: Accept a webhook from Slack's Events API to auto-discover channels, then store configs in Cloudflare KV (free tier includes 1 GB).
- Voice note transcription: If your team uses Slack voice clips, pipe them through Whisper first. The pattern is similar to what we covered in our personal meeting notetaker build.
- Historical trend comparison: Store past digests in KV and ask the LLM to highlight "new since yesterday"—this is where the FDE toolkit for shipping with data and integrations really shines, as we explore in the FDE toolkit deep-dive.
Common Pitfalls
- Slack rate limiting: The tier-3 endpoint
conversations.historyallows ~50 requests per minute. If you're digesting 50+ channels, add a 1-second delay between fetches. Cloudflare Workers canawait new Promise(r => setTimeout(r, 1000))without CPU penalties. - Message truncation: Llama 3 8B has an 8k token context. If a channel is extremely chatty, implement a sliding window or summarize in chunks then merge. The naive 8000-char truncation works for most teams.
- Bot not in channel: The API silently returns no messages if the bot isn't a member. Always invite first.
- Cron timezone: Cloudflare cron is UTC. Adjust the cron expression or handle timezone offsets in code if you need 8 AM local.
- Groq rate limits: Free tier is ~30 RPM. If you're summarizing 20 channels, you'll hit this. Add retry logic with exponential backoff, or batch channels into a single prompt.
FAQ
Q: Can this handle private channels?
A: Yes. Add groups:history and groups:read scopes, invite the bot, and use the same conversations.history endpoint—Slack treats private channels as "groups" but the API is identical.
Q: What if a channel has zero messages yesterday?
A: Our code returns an empty array, and the summarizer will produce a "None" digest for that channel. You can skip posting empty channels with a simple if (messages.length === 0) continue in the main loop.
Q: How do I debug Groq responses?
A: Log the full prompt and response in wrangler tail. Groq's API errors are well-structured—check data.error.message in the response.
Q: Can I use a different LLM?
A: Absolutely. Swap the model field to mixtral-8x7b-32768 (also free on Groq) for longer context, or gemma-7b-it for a different style. The OpenAI-compatible endpoint makes this a one-line change.
Q: Why not just use Slack's built-in summary? A: Slack's AI summary is paywalled behind Enterprise Grid and doesn't allow custom prompt engineering. This bot costs nothing, gives you full control over format, and teaches you the exact integration patterns that matter in production—the same kind of shipping muscle we build in the FDE portfolio projects.
Q: How do I prevent the bot from summarizing its own digest messages?
A: Filter out messages where user === bot_user_id or subtype === 'bot_message' during the fetch step. Grab your bot's user ID from the Slack app dashboard.
Want to build like a Forward Deployed Engineer?
FDE Coach is a cohort-based program in frontend, backend, AWS, and AI. Build real products and get referred to 200+ hiring partners.
Explore the program