Build a Daily Standup Bot with Slack, Supabase & Groq (Free Tier)
What We're Building
A zero-cost engineering artifact: a Slack bot that functions as a digital scrum master. Every weekday morning, it DMs each team member a structured prompt for their standup update. It waits for their reply, stores the raw text in Supabase, then fires a Groq LLM call to synthesize a single, high-signal summary. That summary lands in a public #standup channel before the coffee kicks in.
Crisp Feature List:
- Scheduled DM Collector: Cron-triggered Python process sends a templated Slack DM to a configurable list of users.
- Ephemeral State Machine: Tracks who has replied in a lightweight in-memory or DB-backed loop to avoid duplicate pings.
- Supabase Persistence: Raw standup responses are stored in a
standupstable with user ID, timestamp, and text. - Groq-Powered Synthesis: A single call to
mixtral-8x7b-32768orllama3-70b-8192compresses multiple updates into a concise, action-oriented summary. - Public Channel Post: The generated summary is posted to a designated channel, complete with a date header and optional blocker highlights.
- 100% Free Tier Stack: Groq's free API credits, Slack's free workspace, Supabase's free database, and Python running on a tiny VPS or even a Raspberry Pi.
If you enjoy automating operational workflows, our On-Call Incident Summarizer uses a similar Groq pipeline for postmortem drafts.
Architecture & Data Flow
The orchestration is linear but stateful. The Python process acts as the central conductor: it reaches out to Slack for DM delivery, waits a configurable window for responses, then aggregates and summarizes. No webhook complexity—just a scheduled script that treats Slack's API as a request/response system.
Prerequisites (All Free Tier)
Before writing a single line, grab these accounts and keys. Everything here has a generous free tier that won't cost you a cent for a small to mid-size team.
| Resource | Purpose | Signup Link |
|---|---|---|
| Groq Cloud | LLM inference for summary synthesis | console.groq.com |
| Slack App | Bot user, OAuth tokens, DM and channel write scopes | api.slack.com/apps |
| Supabase | PostgreSQL database for standup storage | supabase.com |
| Python 3.10+ | Runtime (anywhere with cron) | Local, VPS, or GitHub Codespaces |
Groq Rate Limits: Free tier offers ~30 requests per minute and ~14,400 per day. A single synthesis call per day is negligible. Use llama3-8b-8192 for cost-free experimentation or mixtral-8x7b-32768 for longer context windows when summarizing large teams.
Slack Scopes Needed:
chat:write(send DMs and channel messages)users:read(look up user IDs)users:read.email(optional, for mapping emails to IDs)
Step 1: Provision Supabase and Create the Schema
Log into supabase.com, create a new project, and note your SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY. The service role key bypasses Row Level Security—keep it out of client code.
Run this SQL in the Supabase SQL Editor to create the standups table:
CREATE TABLE standups (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id TEXT NOT NULL,
user_name TEXT,
response_text TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_standups_created_at ON standups (created_at DESC);
Why user_id as TEXT: Slack user IDs are strings like U07ABCDEF. No need for UUIDs or foreign keys—we're building a simple log, not an ERP.
Step 2: Bootstrap the Slack App and Secure Tokens
- Go to api.slack.com/apps and click Create New App → From Scratch.
- Name it
Daily Standup Botand pick your workspace. - Under OAuth & Permissions, add the following Bot Token Scopes:
chat:writeusers:readusers:read.email
- Install the app to your workspace. Copy the Bot User OAuth Token (starts with
xoxb-). - Invite the bot to your target channel (
#standup) by typing/invite @Daily Standup Botin that channel.
Critical: The bot must be a member of any channel it posts to. Slack's API will silently fail if the bot isn't in the channel—no helpful error, just a 200 OK with no visible message. Test by manually calling chat.postMessage via their Tester tab.
Step 3: Write the Python Core
Install dependencies:
pip install slack-sdk supabase groq python-dotenv
Create a .env file:
SLACK_BOT_TOKEN=xoxb-your-token
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOi...
GROQ_API_KEY=gsk_your_key
Below is the complete orchestrator. Save it as standup_bot.py.
import os, time
from datetime import datetime, timezone
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
from supabase import create_client, Client
from groq import Groq
from dotenv import load_dotenv
load_dotenv()
# --- Config ---
SLACK_TOKEN = os.getenv("SLACK_BOT_TOKEN")
SUPABASE_URL = os.getenv("SUPABASE_URL")
SUPABASE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY")
GROQ_API_KEY = os.getenv("GROQ_API_KEY")
TARGET_CHANNEL = "#standup"
TEAM_MEMBERS = ["U07ABCDEF", "U07GHIJKL"] # Slack user IDs
DM_WINDOW_SECONDS = 600 # 10 minutes to wait for replies
slack = WebClient(token=SLACK_TOKEN)
supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
groq = Groq(api_key=GROQ_API_KEY)
# --- DM Prompt Template ---
DM_PROMPT = (
"Good morning! :sunny: Time for your daily standup. Reply with:\n"
"1. What did you accomplish yesterday?\n"
"2. What are you working on today?\n"
"3. Any blockers or need for help?"
)
def send_dm(user_id: str) -> str | None:
"""Send standup prompt DM. Returns the DM channel ID on success."""
try:
resp = slack.conversations_open(users=[user_id])
channel_id = resp["channel"]["id"]
slack.chat_postMessage(channel=channel_id, text=DM_PROMPT)
print(f"[DM] Sent to {user_id}")
return channel_id
except SlackApiError as e:
print(f"[DM] Failed for {user_id}: {e.response['error']}")
return None
def collect_replies(dm_map: dict[str, str]) -> list[dict]:
"""Poll DM channels for replies. Returns list of {user_id, text}."""
deadline = time.time() + DM_WINDOW_SECONDS
collected = []
pending = set(dm_map.keys())
while pending and time.time() < deadline:
for user_id in list(pending):
try:
history = slack.conversations_history(
channel=dm_map[user_id], limit=2
)
for msg in history["messages"]:
if msg.get("user") == user_id and not msg.get("bot_id"):
collected.append({"user_id": user_id, "text": msg["text"]})
pending.remove(user_id)
print(f"[Reply] Collected from {user_id}")
break
except SlackApiError:
pass
time.sleep(10) # Poll interval
return collected
def save_to_supabase(updates: list[dict]):
"""Persist raw standup responses."""
for u in updates:
supabase.table("standups").insert({
"user_id": u["user_id"],
"response_text": u["text"],
}).execute()
print(f"[DB] Saved {len(updates)} responses")
def synthesize_summary(updates: list[dict]) -> str:
"""Call Groq to synthesize a team standup summary."""
if not updates:
return "No standup updates received today."
combined = "\n\n".join(
f"User {u['user_id']}: {u['text']}" for u in updates
)
system_prompt = (
"You are a concise scrum master. Summarize the following standup updates "
"into a single Slack post. Group by theme: accomplishments, planned work, blockers. "
"Highlight any blockers that need immediate attention. Use emojis sparingly. "
"Keep the tone professional but warm."
)
response = groq.chat.completions.create(
model="llama3-8b-8192",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": combined}
],
temperature=0.3,
max_tokens=600
)
summary = response.choices[0].message.content
print(f"[Groq] Summary generated ({len(summary)} chars)")
return summary
def post_summary(summary: str):
"""Post the synthesized summary to the target Slack channel."""
today = datetime.now(timezone.utc).strftime("%A, %B %d")
header = f"*:mega: Standup Summary — {today}*\n\n"
try:
slack.chat_postMessage(
channel=TARGET_CHANNEL,
text=header + summary
)
print(f"[Post] Summary posted to {TARGET_CHANNEL}")
except SlackApiError as e:
print(f"[Post] Failed: {e.response['error']}")
# --- Main Orchestration ---
def run_standup():
print(f"[Start] Standup bot triggered at {datetime.now(timezone.utc).isoformat()}")
# 1. Send DMs
dm_map = {}
for uid in TEAM_MEMBERS:
channel = send_dm(uid)
if channel:
dm_map[uid] = channel
if not dm_map:
print("[Error] No DMs sent successfully. Aborting.")
return
# 2. Collect replies
updates = collect_replies(dm_map)
# 3. Persist
save_to_supabase(updates)
# 4. Synthesize
summary = synthesize_summary(updates)
# 5. Post
post_summary(summary)
print("[Done] Standup cycle complete.")
if __name__ == "__main__":
run_standup()
Design Decisions:
- Polling, not Events: We poll
conversations.historyinstead of using Slack's Events API. This avoids needing a public HTTP endpoint and keeps the bot runnable from any cron-capable machine. - 10-Minute Window: Adjust
DM_WINDOW_SECONDSbased on team responsiveness. A shorter window means faster summary; a longer window catches stragglers. - Groq Model Choice:
llama3-8b-8192is blazing fast (sub-second) and free-tier friendly. Swap tomixtral-8x7b-32768if you have 10+ team members with verbose updates.
For a deeper dive into synthesizing operational data with Groq, see our On-Call Incident Summarizer guide.
Step 4: Deploy the Cron Scheduler
The bot is a single Python script. It needs to run once per weekday. The simplest free approach:
Option A: GitHub Actions (Free)
Create .github/workflows/standup.yml:
name: Daily Standup
on:
schedule:
- cron: '30 8 * * 1-5' # 8:30 AM UTC, Mon-Fri
jobs:
standup:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: pip install slack-sdk supabase groq python-dotenv
- run: python standup_bot.py
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
Add your secrets in the repository's Settings → Secrets and Variables → Actions.
Option B: Local Cron (Raspberry Pi / Old Laptop)
crontab -e
# Add: 30 8 * * 1-5 cd /path/to/bot && python standup_bot.py
Option C: Supabase Edge Function (Cron)
Supabase now offers built-in cron triggers via pg_cron. You can invoke a Deno Edge Function on a schedule, but Python support is limited. Stick with GitHub Actions for zero-infrastructure deployment.
Running the Bot End-to-End
- Set environment variables in your chosen runtime.
- Update
TEAM_MEMBERSwith actual Slack user IDs. Find them by clicking a user's profile → Copy Member ID (you may need to enable this in Slack's advanced settings). - Test manually first: Run
python standup_bot.pyfrom your terminal. Check that DMs arrive, replies are collected, and the summary posts to#standup. - Enable the cron and verify the next morning.
Sensible Extensions
- User Name Resolution: Call
slack.users_info(user=uid)to fetchreal_nameand include it in the summary for human-readable output. - Blocker Escalation: If Groq detects a blocker (prompt it to return structured JSON), automatically
@channelor DM a lead. - Standup History Dashboard: Build a simple frontend on Supabase's REST API to view past standups. This is a natural next step for an FDE who wants visibility into team throughput patterns.
- Multi-Timezone Support: Offset the cron per user or stagger DM sends based on timezone data from Slack profiles.
- Sentiment Tracking: Use Groq to score each update's sentiment and track team morale over time in a separate Supabase table.
Common Pitfalls and Debugging
| Pitfall | Symptom | Fix |
|---|---|---|
| Bot not in channel | 200 OK but no message appears | /invite @bot in the target channel |
| Expired Slack token | invalid_auth error | Reinstall the Slack app, copy new token |
| Groq rate limit | 429 errors | Add time.sleep(2) between retries or switch to llama3-8b |
| Supabase RLS blocking insert | 401 or empty insert | Use the service role key, not the anon key |
| GitHub Actions timezone | Cron runs at UTC, not local | Adjust the cron expression; 8:30 UTC = 4:30 AM ET |
| DM window too short | Missing replies from slow responders | Increase DM_WINDOW_SECONDS to 900 (15 min) |
FAQ
Q: Can I use this with a team spread across time zones?
Yes. Fetch each user's tz from users.info and stagger DM sends. The collector loop can run longer to accommodate late replies.
Q: What if someone doesn't reply?
The summary will simply omit them. Optionally, post a note: "No update from @user today." Add a pending set check after the window expires.
Q: Is Groq really free for this? Absolutely. Groq's free tier is generous. A single synthesis call of ~500 tokens per day is well within limits. You won't hit the rate cap even with retries.
Q: How do I add more team members?
Update the TEAM_MEMBERS list. For dynamic teams, fetch the list from a Slack User Group or a Supabase config table.
Q: Can I store standups in a different database?
Yes. The Supabase client is just a PostgreSQL wrapper. Swap it for psycopg2, SQLite, or even a JSON file. Supabase is chosen for its free tier and hosted convenience.
Q: What if the bot posts the same summary twice? Add a deduplication check: before posting, query Supabase for a summary with today's date. If one exists, skip or append an "Updated" note.
Ready to level up your automation skills? FDE Coach builds engineers who ship internal tools like this in their first week. Check out our guide on what a Forward Deployed Engineer actually does in a week to see where projects like this fit into the broader FDE workflow.
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