Task tracking in Slack threads. Dead simple.
A self-hosted Slack bot that turns every message in its dedicated channels into a task. People claim it and set its status with reactions. Homepage: itakeit.nice.pink.
- Post an issue in one of the channels and it becomes a task. The bot replies in its thread with a status card.
- React π (
:raising_hand:) on the issue to take it. Several people can own one task. Remove the reaction to hand it back. - Owners set the status with reactions: π investigating, π§ in progress, β needs info, β blocked, β done.
- β pings the reporter in the thread. When the reporter replies, the bot pings the owners and clears the status.
- Lines written as
[ ] itemin the issue become a checklist on the status card. Anyone can tick them. - A pinned board message in each channel counts the open tasks in each status and lists the oldest
board_max_tasksof them with their status and owners. - Owners who stay silent on a task they are working on get a reminder in the thread, repeated every
stale_after_hoursuntil an owner posts in the thread or the status changes.
It uses Socket Mode, so it needs no public URL, ingress or TLS. It runs as a single process with a SQLite file, or with Postgres when a database URL is configured.
- Go to https://api.slack.com/apps, choose Create New App, then From an app manifest, and pick your workspace.
- Paste the contents of
slack-app-manifest.yaml(YAML tab) and create the app. - Under Basic Information β App-Level Tokens, choose Generate Token and Scopes, name it
socket, add the scopeconnections:writeand generate it. Copy thexapp-...token. This isSLACK_APP_TOKEN. - Under Install App, install the app to the workspace. Copy the Bot User OAuth Token (
xoxb-...). This isSLACK_BOT_TOKEN.
The manifest requests these bot scopes:
| Scope | Used for |
|---|---|
channels:history, groups:history |
receiving messages in a public or private channel, and looking up messages posted before the bot was running |
channels:read, groups:read |
noticing when the bot is invited or removed, so a board appears at once, and with auto_channels listing the channels it is a member of |
chat:write |
status cards, the board, pings, private "only owners canβ¦" hints |
reactions:read |
receiving reaction events, and with status_claims checking who still reacts on a task |
pins:write |
pinning the board |
users:read |
reading a user's time zone, so "remind me tomorrow" means their morning |
The manifest also turns on Interactivity, which the checklist checkboxes need. Socket Mode delivers the clicks, so no request URL is required. An app created from an older manifest needs it switched on under Interactivity & Shortcuts.
If you change scopes later, reinstall the app so they take effect. An app installed before reminders existed needs users:read added this way. Without it, remind me still works but computes times in UTC.
One instance serves any number of channels. Each has its own tasks and board, and all share the settings in config.yaml. For every channel:
- Create the channel, e.g.
#itakeit. Public or private both work. - Invite the bot:
/invite @itakeit. It only sees channels it is a member of. The board appears as soon as it joins. - Copy the channel ID: open the channel name, then About, and find it at the bottom (
C0123456789).
With auto_channels: true instead of a channels list, the bot serves every public or private channel it is a member of, and step 3 goes away: inviting the bot is what turns a channel into a task channel, and removing it (/remove @itakeit) or archiving the channel stops boards and reminders there. Checklist clicks on old cards keep working in a channel the bot still belongs to, and are ignored once it was removed. The bot re-lists its channels on every reminder check (at most every 15 minutes), so an invite, removal, archive or unarchive whose event was missed or arrived out of order is corrected by then. Invite it only where every top-level message should become a task. With a channels list, an invite to an unlisted channel is ignored.
cp config.example.yaml config.yaml
Set channels to the IDs from step 2, or set auto_channels: true. The older single channel: C0123456789 key still works. Under auto_channels a channel key is ignored (with a warning in the log), so a config.yaml that still sets channel for an older itakeit-agent keeps working. itakeit-agent reads the same channels and auto_channels keys and works in the same channels. An app created before auto_channels lacks the channels:read and groups:read scopes and the member events: update it from the current manifest and reinstall it. Without them a channels list still works, but the board of a newly invited channel appears only with its first task, and auto_channels finds a channel only from its first message or reaction. Every other field has a default, and all fields are explained in the example file. Tokens come only from the environment and never go into the config file.
./build && SLACK_BOT_TOKEN=xoxb-... SLACK_APP_TOKEN=xapp-... ./bin/itakeit -config config.yaml
The log should show authenticated and then connected to slack, and a board message appears pinned in each channel. -debug logs the raw Socket Mode traffic.
Pull the published image from GitHub Container Registry:
docker run -d --name itakeit --restart unless-stopped -e SLACK_BOT_TOKEN -e SLACK_APP_TOKEN -v "$PWD/config.yaml:/config/config.yaml:ro" -v itakeit-data:/data ghcr.io/nice-pink/itakeit:latest
latest follows main. Release tags vX.Y.Z also publish X.Y.Z and X.Y, and every build publishes sha-<short>. Images are built for linux/amd64 and linux/arm64.
Or build it yourself:
docker build -t itakeit . && docker run -d --name itakeit --restart unless-stopped -e SLACK_BOT_TOKEN -e SLACK_APP_TOKEN -v "$PWD/config.yaml:/config/config.yaml:ro" -v itakeit-data:/data itakeit
With SQLite, set db_path: /data/itakeit.db in config.yaml so state survives container restarts. The runtime image runs as uid 65532 and creates /data owned by that user, so the named volume above is writable. If you bind-mount a host directory instead, make it writable for uid 65532. The bot makes only outbound connections, so it needs no port.
examples/ has a Docker Compose setup with Postgres and Kubernetes manifests for SQLite and for Postgres; see examples/README.md.
Set ITAKEIT_DATABASE_URL to use Postgres instead of SQLite, for example ITAKEIT_DATABASE_URL=postgres://itakeit:secret@db:5432/itakeit (add -e ITAKEIT_DATABASE_URL to docker run), or set database_url in config.yaml. The environment variable wins when both are set; prefer it, because the URL holds the password. db_path is then ignored. Connecting times out after 10 s unless the URL sets a non-zero connect_timeout. Fields the URL leaves out (host, user, password, sslmode) fall back to the standard PG* environment variables and ~/.pgpass, so give the full URL when the environment carries another service's. The bot creates its tables on start (tasks, owners, checks, kv). These names are generic, so give it its own database, or its own schema: run CREATE SCHEMA itakeit first and add search_path=itakeit to the URL's query string. Existing SQLite data is not copied over; the bot starts empty and tasks come back as people react.
Run exactly one instance per Slack app, with all its channels in one config. Slack delivers each Socket Mode event to only one of an app's open connections, so two instances would each miss events meant for the other. A second, separately configured set of channels needs its own Slack app.
Merging instances that each served one channel: give the merged instance one database (Postgres, or the SQLite file of one of them). Channels whose state was in another database start empty, and the bot pins a fresh board in each; unpin the old one. Tasks come back as people react. A channel removed from channels, or with auto_channels a channel the bot was removed from or that was archived, keeps its rows in the database, and done_retain_days no longer cleans them up.
| Who | Does | Effect |
|---|---|---|
| anyone | invites the bot to a channel listed in channels, or to any channel with auto_channels |
the channel's board is posted and pinned |
| anyone | removes the bot from a channel, or archives it, with auto_channels |
boards, reminders and checklist clicks stop there; tasks stay in the database |
| anyone | posts a top-level message in a served channel | new task, status card in its thread, board updated |
| anyone | reacts π on the task message | becomes an owner |
| owner | removes π | stops owning it. When the last owner leaves, the status resets to unclaimed, except that a done task stays done. |
| owner | reacts π / π§ / β / β | sets the status. The most recent reaction wins. β removes the task from the board. |
| owner | removes the reaction for the current status | status falls back to claimed |
| owner | reacts β | reporter is pinged in the thread |
| reporter | replies in the thread while β is set | owners are pinged and the status falls back to claimed |
| owner | replies in the thread | counts as activity and resets the reminder clock |
| non-owner | reacts with a status emoji | ignored, and the user gets a private hint to claim first. With status_claims: true it makes them an owner and sets the status instead. |
| owner | removes their last π or status reaction, with status_claims: true |
stops owning it. An owner who still has any π or status reaction on the message stays one. |
| anyone | replies remind me <when> in the thread |
the bot sends them a DM with the task link when it is due, and confirms privately. It is not task activity. <when> is tomorrow, a weekday, in 30m, in 3 hours, in 2 days, in 1w, at 15:00, or a day plus a time (tomorrow at 3pm). A day alone means 09:00 in their Slack time zone (UTC if it cannot be read). A reply that starts with remind me but cannot be read gets a private hint. Neither counts as task activity. One reminder per person and task, a new one replaces it, and it is dropped if the task is done. A DM that fails is retried for 6 hours. |
| anyone | ticks a checklist item on the status card | card and board show progress. An owner's tick counts as activity. Ticking the last item pings the owners (or the reporter, if unclaimed) to set β . |
Reactions only count on the task message itself. Reactions on thread replies, the card or the board are ignored. Skin tone variants count as the base emoji.
Messages posted while the bot was offline are not lost. The first π or status reaction on such a message turns it into a task, but only π makes the reactor an owner, unless status_claims is on. Reactions removed while the bot was offline are not replayed. Remove the emoji and add it again to resync.
A checklist line is [ ] text or [x] text on its own line, optionally after a list bullet, so a Slack bulleted list works. [x] starts ticked. Once an item is ticked or unticked on the card, the card wins over the message's [ ]/[x]. Ticks are stored by the item's text, so they survive reordering the message. Changing an item's text, or deleting an earlier line with the same text, starts it over from what the message says. The card shows up to 48 items and counts the rest. Items past 48 can only be ticked with [x] in the message. Cards posted before checklist support get their checkboxes on the task's next change.
Editing the task message updates its line on the board. Deleting it removes the task and its status card.
To reopen a done task that has no owners left, claim it with π and then set any other status. With status_claims on, setting the other status is enough.
Messages from integrations and webhooks count as tasks too, with the integration's name as the reporter. The bot can't @-mention them, so β posts their name as plain text, unless bot_contact names a Slack user (bot_contacts overrides it per channel ID, also under auto_channels): that user becomes the reporter of those tasks and is pinged by β instead, and their reply clears the status.
- Different emoji: edit the
emojiblock. Custom workspace emoji work (e.g.claim: [itakeit]), and each action can list several emoji. If the block is present it replaces the defaults, so list every action you want. - Disabling an action: leave it out of
emoji.claimis required. - Status without claiming:
status_claims: truelets anyone set a status. Everyone who adds a π or status reaction while the bot is running becomes an owner, and stays one until they have none left on the message. Reactions added while the bot was offline, or before the flag was on, don't count until re-added. When the bot can't read the message's reactions after a removal, it keeps the owner. Turning the flag off later leaves status-only owners in place; they hand a task back by adding and removing π. - Reminders: raise or lower
stale_after_hours. Tasks that are blocked or waiting for info are never nudged. - Database cleanup: set
done_retain_daysto delete done tasks from the database after that many days without activity, checked hourly. Defaults to 30. Set-1to keep them forever. Messages and cards stay in Slack, but a swept task is frozen: reactions on it are ignored, deleting it leaves its card, and it can't be reopened. Messages older thandone_retain_daysare also never adopted, so a message missed while the bot was offline that long is not picked up.
cmd/itakeit startup: config, tokens, auth test, Socket Mode client
pkg/config YAML config, reaction -> action lookup
pkg/task task model, state transitions, card/board rendering (pure, no I/O)
pkg/bot Socket Mode event loop and Slack calls
pkg/store SQLite or Postgres persistence
- One goroutine handles every event and the reminder timer, so there is no locking in the application.
- A separate goroutine acknowledges each event the moment it arrives, so a slow Slack API call never delays an ack. Events still queued when the bot stops or crashes are not redelivered. Re-adding the reaction repairs it. A redelivered message never resets a task that already exists.
- Slack API calls time out after 15 s and are retried up to 3 times on rate limits.
- The board is posted as a placeholder and filled by an edit, because edits don't notify anyone, and reposting the board would otherwise ping every owner.
- The channel is the record of what was said. The database holds only owners, statuses, timestamps, checklist ticks and each channel's board message ID. Deleting the database loses claims and statuses but no conversation. The next start posts a fresh board in each channel, and tasks come back as people react.
- If someone deletes the board or a status card, the bot posts and pins a new one on the next change.
./build
This runs go test ./... and builds bin/itakeit. The bot tests drive the real event handlers against a fake Slack API and a temporary SQLite file. ITAKEIT_TEST_DATABASE_URL=postgres://... also runs the store tests against Postgres, in a throwaway schema.
GPL-3.0, see LICENSE.