A Slack bot that DMs each user their previous standup reply when a new standup thread appears. Runs via Socket Mode — no public URL or inbound firewall rules needed.
One instance serves any number of teams. Each channel gets its own thread identifier, so teams whose standup message is worded differently can share a single bot and Slack app.
- Create a new Slack app at https://api.slack.com/apps
- Enable Socket Mode under Settings and generate an App-Level Token with the
connections:writescope - Add the following Bot Token Scopes under OAuth & Permissions:
channels:history— read messages in public channelsgroups:history— read messages in private channelschat:write— send messages as the botim:write— send direct messagesim:history— read DM messages (for subscribe/unsubscribe commands)
- Subscribe to these events under Event Subscriptions:
message.channelsmessage.groupsmessage.im
- Install the app to your workspace
- Invite the bot to each standup channel (
/invite @your-bot) — including private ones
curl -fsSL https://raw.githubusercontent.com/j4rs/standup-echo/main/install.sh | sudo bashInstalls Go and Git if needed, builds the binary to /usr/local/bin, and creates a systemd service.
brew tap j4rs/tools
brew install standup-echogit clone https://github.com/j4rs/standup-echo.git
cd standup-echo
make buildstandup-echo configureYou'll be prompted for:
- Slack Bot Token (
xoxb-...) — from OAuth & Permissions - Slack App Token (
xapp-...) — the App-Level Token withconnections:write - Then, for each channel:
- Channel ID — right-click the channel in Slack > Copy link > extract the ID
- Thread Identifier — text from the standup message body (see below)
- Name — optional short label used in logs and
trigger --channel
Config is saved to ~/.config/standup-echo/config.yml:
slack_bot_token: xoxb-...
slack_app_token: xapp-...
channels:
- name: m1
channel_id: C0123456789
thread_identifier: Daily Standup
- name: m2
channel_id: C9876543210
thread_identifier: async standup check-inPre-multi-channel configs with top-level channel_id / thread_identifier still
load — they're folded into channels automatically.
The identifier is matched against the message text, not the sender's name. This
matters for standups posted by a Slack Workflow: a workflow named "Daily Standup"
whose message reads "Hey team, it's time for our daily async standup check-in!"
will never match Daily Standup — that string is only the display name. Pick a
distinctive phrase from the body itself, e.g. async standup check-in.
To check what the bot actually sees, run standup-echo trigger --channel <name>
after inviting the bot; it logs found standup thread on a match and
no standup thread found otherwise.
standup-echo servebrew services start standup-echoThe bot starts on login and watches for new standup threads. When one appears, it finds the previous thread, collects each user's reply, and DMs subscribed users their update as a ready-to-edit template.
The bot is opt-in only. Each user must DM the bot to subscribe:
- Send
subscribeto start receiving standup reminders - Send
unsubscribeto stop
Subscriber data is stored locally at ~/.config/standup-echo/standup-echo.db.
Subscribing is a single global opt-in — there's no need to name a channel. A DM is only ever sent to someone who replied in that channel's previous standup thread, so the reply itself establishes which team you're on. Anyone in two standup channels gets one DM per channel.
| Command | Description |
|---|---|
standup-echo serve |
Start the bot daemon |
standup-echo trigger |
Manually re-send DMs for a thread |
standup-echo configure |
Interactive configuration setup |
standup-echo version |
Print version information |
Useful for verifying a new channel's identifier or re-sending a missed day:
standup-echo trigger --channel m2 # target the newest m2 thread
standup-echo trigger --channel m2 --date 2026-08-13
standup-echo trigger --channel m2 --user U0123456 # limit delivery to one person--channel takes a channel ID or the configured name, and is required when more
than one channel is configured.
trigger runs the same path as serve: it locates the target thread, then echoes
the preceding thread's replies into it. A trigger run is therefore a faithful
rehearsal of the live flow, not an approximation of it.
To check a channel's configuration without DMing anyone, pass a --user that has no
reply in the thread — the run stops before delivery, having already proven channel
access, identifier matching, and reply collection:
standup-echo trigger --channel m2 --user UDRYRUN00000Every run logs a summary line — finished sending DMs sent=N skipped_not_subscribed=N failed=N — so a run that delivers nothing explains itself rather than looking broken.
The most common cause of sent=0 is a recipient who hasn't sent subscribe.