Building a Standup Bot in n8n + Slack
TL;DR — Two n8n workflows. Workflow A (cron at 9 AM) DMs each team member with three standup questions via Slack Block Kit. Workflow B (interactive webhook) collects responses + posts a daily summary in #standup. Skips weekends + holidays. Saves the team ~12 min/day.
After error handling, here’s a full working bot that ties together cron triggers, Slack interactivity, and Jira lookups from earlier in the month. Standup bot is the canonical “automate the toil” example.
What the bot does
The user experience:
9:00 AM, weekday: Bot DMs each team member:
Good morning! Standup time. Reply with:
- What you did yesterday
- What you’ll do today
- Any blockers
User replies (in-thread or via modal).
9:30 AM, weekday: Bot posts daily summary in #standup channel, grouped by person.
That’s it. Two synchronous events, no daily meeting required.
Setup: the Slack app
If you already have the app from slash commands, extend it. Otherwise create a fresh one.
Bot Token Scopes needed:
chat:write— post to channelsim:write— open DMs to usersusers:read— list workspace members for “team” lookupusers.profile:read— for display names
OAuth, install to workspace, copy the Bot User OAuth Token. Store in n8n’s credentials store.
For receiving user responses interactively, also enable:
- Event Subscriptions: enable, set Request URL to an n8n webhook
- Subscribe to bot events:
message.im(DMs received by the bot) - Interactivity & Shortcuts: enable, set Request URL to an n8n webhook (for modals — optional path)
Workflow A: morning prompts (cron)
[Schedule: 0 9 * * 1-5 TZ Asia/Jakarta]
↓
[HTTP: Slack users.list (or maintain a small "team" config workflow data)]
↓
[Filter: keep only team members, drop bots and deactivated users]
↓
[Loop: for each user]
↓
[Slack: chat.postMessage to user's DM channel with Block Kit prompt]
The prompt as Block Kit JSON:
{
"text": "Standup time",
"blocks": [
{ "type": "header", "text": { "type": "plain_text", "text": "🌅 Good morning! Standup time." } },
{ "type": "section", "text": { "type": "mrkdwn", "text": "Please reply with three updates today:" } },
{ "type": "section", "text": { "type": "mrkdwn", "text": "*1. What did you do yesterday?*\n*2. What will you do today?*\n*3. Any blockers?*" } },
{ "type": "actions", "elements": [
{ "type": "button", "text": { "type": "plain_text", "text": "Open form" }, "action_id": "standup_open", "style": "primary" }
]}
]
}
The button opens a Slack modal (next workflow handles it). For users who prefer free-text in DM, they can also just reply — the message-event handler workflow picks that up too.
In the Slack node:
Resource: Message
Operation: Post
Channel: {{ $json.id }} (the user's user-ID, which Slack accepts for DMs)
Blocks: {{ JSON.stringify(blocks) }}
Workflow B: handle modal submission
Slack POSTs to your interactivity URL when a button is clicked or modal submitted. The payload tells you which action.
[Webhook: interactivity URL]
↓
[Code: verify signature (as in slash commands post)]
↓
[IF: payload.type === 'block_actions' && action_id === 'standup_open']
│
└─ [HTTP: views.open to show modal]
[IF: payload.type === 'view_submission' && view.callback_id === 'standup_form']
│
└─ [Code: extract three answers + user_id]
[Postgres: insert into standup_responses (user_id, date, yesterday, today, blockers)]
[Respond: empty 200]
The modal is also Block Kit JSON:
{
"type": "modal",
"callback_id": "standup_form",
"title": { "type": "plain_text", "text": "Daily Standup" },
"submit": { "type": "plain_text", "text": "Submit" },
"blocks": [
{ "type": "input", "block_id": "yesterday", "label": { "type": "plain_text", "text": "Yesterday" },
"element": { "type": "plain_text_input", "action_id": "value", "multiline": true } },
{ "type": "input", "block_id": "today", "label": { "type": "plain_text", "text": "Today" },
"element": { "type": "plain_text_input", "action_id": "value", "multiline": true } },
{ "type": "input", "block_id": "blockers", "label": { "type": "plain_text", "text": "Blockers (optional)" },
"optional": true,
"element": { "type": "plain_text_input", "action_id": "value", "multiline": true } }
]
}
Submit fires another webhook with type=view_submission and the answers in view.state.values.
Workflow C: post daily summary
Separate cron, fires 9:30 AM:
[Schedule: 30 9 * * 1-5]
↓
[Postgres: SELECT * FROM standup_responses WHERE date = today]
↓
[Code: format as Slack-flavored markdown grouped by user]
↓
[Slack: post in #standup]
Format:
const items = $input.all();
const responses = items.map(i => i.json);
if (responses.length === 0) {
return [{ json: { text: 'No standup responses today 🦗' } }];
}
const parts = responses.map(r =>
`*<@${r.user_id}>*\n` +
`*Yesterday:* ${r.yesterday}\n` +
`*Today:* ${r.today}\n` +
(r.blockers ? `*Blockers:* ${r.blockers}\n` : '')
);
const text = `*🗒️ Standup — ${new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' })}*\n\n${parts.join('\n')}`;
return [{ json: { text } }];
Post to #standup. Done.
Holiday handling
The simplest cron rejects weekends (* * * * 1-5). Holidays are harder.
Two approaches:
Static config: A data/holidays.yml file listing holidays for the year. Workflow A and C check today in holidays; skip if true.
API: Use a holiday API (Nager.Date is free) at the start of each cron run. Skip if today is a holiday in your country.
We use static. It’s once-a-year maintenance.
Track + post Jira progress alongside
Bonus: in the daily summary, include each user’s Jira “In Progress” tickets, fetched live. Pattern:
// For each user, after their text answers:
const tickets = await jiraSearch(`assignee = "${r.user_email}" AND status = "In Progress"`);
const ticketLines = tickets.map(t => ` • <${t.url}|${t.key}> ${t.summary}`).join('\n');
const block = `*<@${r.user_id}>*\n*Yesterday:* ${r.yesterday}\n*Today:* ${r.today}\n*In Jira:*\n${ticketLines}`;
Now the standup post has live context: text from humans + ground truth from Jira. The intersection is more useful than either alone.
Real-world metrics
For our team after deploying this:
- Daily standup meeting: 25 min × 6 people = 150 min/day → eliminated
- Async standup time per person: ~3 min answering modal
- Total team time per day: ~18 min (was 150)
- Adoption: 5/6 of the team uses the modal; one prefers free-text DM
The big surprise: people write better updates async than they speak them. The summary thread is more useful than the meeting was.
Common Pitfalls
Sending the prompt at 8 AM when half the team starts at 10. Adjust to your team’s actual hours.
Reminding people who already submitted. Workflow A should check the responses table; skip users who already submitted today.
Modal that doesn’t pre-fill with yesterday’s “today” as “yesterday.” People appreciate having last-day’s “today” as a starting point for today’s “yesterday.” Bonus polish.
No way to skip the day. People take days off, OOO, sick. Either reply with /off in DM or skip by ignoring; bot shouldn’t escalate.
The Slack signature verification gotcha from slash commands applies here too. Modals come through the interactivity URL; signatures verify the same way.
Posting to #standup at 9:30 AM regardless of submissions. If nobody submitted, the summary is empty. Either skip the post, or post a polite “no responses” message.
Workflow accumulates execution data. Each run is a webhook + DB write. Watch retention; covered in the self-host post.
Wrapping Up
Three workflows (morning prompt, interactive handler, daily summary post), one Postgres table, ~150 min/day of meeting time replaced with ~18 min of writing. The standup bot is the highest-leverage internal automation we’ve shipped. Wednesday: securing n8n — credentials, OAuth, encryption, and how to keep this all secure.